Commit ec58ec474bfe48fefb295d140da4f1354328fa4b
1 parent
fec84ce8
A: ai_chat_event MySQL ledger (append-only, iSeq/iTurn, Redis hot cache) + token…
…-budget projection (conservative estimator, in-turn demotion, pre-send self-check, prompt_eval_count calibration)
Showing
15 changed files
with
644 additions
and
159 deletions
sql/ai_chat_event.sql
0 → 100644
| 1 | +-- ai_chat_event:会话事件账本(唯一事实源,append-only,永不删除)。 | ||
| 2 | +-- 每事件一行:一句话 / 一次工具调用 / 一次工具结果 / 一次按钮点击,比"轮"更细。 | ||
| 3 | +-- 字段按 ERP 惯例命名(sMakePerson/tCreateDate/sBrandsId/sSubsidiaryId)。 | ||
| 4 | +-- sPayload = 事件 JSON 全文(含 t/type 与业务字段,与 Redis 热缓存同格式)。 | ||
| 5 | +-- iSeq = 会话内序号 1..n(服务端赋值,UNIQUE 防并发撞号;纯溯源列,不进投影/API)。 | ||
| 6 | +-- iTurn = 轮次 1..m(user/form_submit 开新轮,其余事件继承当前轮;纯溯源列)。 | ||
| 7 | +-- Redis(chat:ledger:{convId},30 天 TTL)只是热缓存:读优先走 Redis,缓存失效回源本表。 | ||
| 8 | +CREATE TABLE IF NOT EXISTS ai_chat_event ( | ||
| 9 | + iId bigint NOT NULL AUTO_INCREMENT PRIMARY KEY, | ||
| 10 | + sConversationId varchar(96) NOT NULL, | ||
| 11 | + iSeq int NOT NULL, | ||
| 12 | + iTurn int NOT NULL DEFAULT 1, | ||
| 13 | + sMakePerson varchar(64) NULL, | ||
| 14 | + sBrandsId varchar(32) NULL, | ||
| 15 | + sSubsidiaryId varchar(32) NULL, | ||
| 16 | + sType varchar(32) NOT NULL, | ||
| 17 | + sPayload mediumtext, | ||
| 18 | + tCreateDate datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 19 | + UNIQUE KEY uk_conv_seq (sConversationId, iSeq), | ||
| 20 | + KEY idx_conv (sConversationId, iId) | ||
| 21 | +); |
src/main/java/com/xly/agent/AgentIdentity.java
| @@ -18,12 +18,15 @@ public final class AgentIdentity { | @@ -18,12 +18,15 @@ public final class AgentIdentity { | ||
| 18 | private final String token; | 18 | private final String token; |
| 19 | private final String userId; | 19 | private final String userId; |
| 20 | private final String brandsId; | 20 | private final String brandsId; |
| 21 | + private final String subsidiaryId; | ||
| 21 | private final Set<String> grantedModuleIds; // null = 全部(管理员) | 22 | private final Set<String> grantedModuleIds; // null = 全部(管理员) |
| 22 | 23 | ||
| 23 | - public AgentIdentity(String token, String userId, String brandsId, Set<String> grantedModuleIds) { | 24 | + public AgentIdentity(String token, String userId, String brandsId, String subsidiaryId, |
| 25 | + Set<String> grantedModuleIds) { | ||
| 24 | this.token = token; | 26 | this.token = token; |
| 25 | this.userId = userId; | 27 | this.userId = userId; |
| 26 | this.brandsId = brandsId; | 28 | this.brandsId = brandsId; |
| 29 | + this.subsidiaryId = subsidiaryId; | ||
| 27 | this.grantedModuleIds = grantedModuleIds; | 30 | this.grantedModuleIds = grantedModuleIds; |
| 28 | } | 31 | } |
| 29 | 32 | ||
| @@ -40,6 +43,10 @@ public final class AgentIdentity { | @@ -40,6 +43,10 @@ public final class AgentIdentity { | ||
| 40 | return brandsId; | 43 | return brandsId; |
| 41 | } | 44 | } |
| 42 | 45 | ||
| 46 | + public String subsidiaryId() { | ||
| 47 | + return subsidiaryId; | ||
| 48 | + } | ||
| 49 | + | ||
| 43 | public boolean canAccessModule(String moduleId) { | 50 | public boolean canAccessModule(String moduleId) { |
| 44 | return grantedModuleIds == null || (moduleId != null && grantedModuleIds.contains(moduleId)); | 51 | return grantedModuleIds == null || (moduleId != null && grantedModuleIds.contains(moduleId)); |
| 45 | } | 52 | } |
src/main/java/com/xly/agent/EventLogChatMemory.java
| @@ -5,7 +5,6 @@ import com.xly.service.LedgerService; | @@ -5,7 +5,6 @@ import com.xly.service.LedgerService; | ||
| 5 | import dev.langchain4j.data.message.AiMessage; | 5 | import dev.langchain4j.data.message.AiMessage; |
| 6 | import dev.langchain4j.data.message.ChatMessage; | 6 | import dev.langchain4j.data.message.ChatMessage; |
| 7 | import dev.langchain4j.agent.tool.ToolExecutionRequest; | 7 | import dev.langchain4j.agent.tool.ToolExecutionRequest; |
| 8 | -import dev.langchain4j.data.message.ChatMessageSerializer; | ||
| 9 | import dev.langchain4j.data.message.SystemMessage; | 8 | import dev.langchain4j.data.message.SystemMessage; |
| 10 | import dev.langchain4j.data.message.ToolExecutionResultMessage; | 9 | import dev.langchain4j.data.message.ToolExecutionResultMessage; |
| 11 | import dev.langchain4j.data.message.UserMessage; | 10 | import dev.langchain4j.data.message.UserMessage; |
| @@ -17,14 +16,17 @@ import java.util.List; | @@ -17,14 +16,17 @@ import java.util.List; | ||
| 17 | import java.util.Map; | 16 | import java.util.Map; |
| 18 | 17 | ||
| 19 | /** | 18 | /** |
| 20 | - * 事件日志上的对话记忆:{@link #add} 把模型环消息逐条 append 成事件(rightPush 原子—— | ||
| 21 | - * 对话流与确认端点并发写互不覆盖,替代旧 chat:mem 整包读改写),{@link #messages()} 读取 | ||
| 22 | - * {@link EventProjectionService} 的四段式投影。日志即唯一事实源,本类不持有任何会话状态。 | 19 | + * 事件账本上的对话记忆:{@link #add} 把模型环消息逐条 append 成事件(追加原子—— |
| 20 | + * 对话流与按钮端点并发写互不覆盖),{@link #messages()} 读取 | ||
| 21 | + * {@link EventProjectionService} 的四段式 token 记账投影。账本即唯一事实源,本类不持有任何会话状态。 | ||
| 23 | * | 22 | * |
| 24 | * <p>写者分工:用户事件由控制器在收到请求时先落账(前端立即可见),本类对 UserMessage 做 | 23 | * <p>写者分工:用户事件由控制器在收到请求时先落账(前端立即可见),本类对 UserMessage 做 |
| 25 | * 去重跳过;{@code internalUserTurn=true} 时(反编造护栏重试的注入话术)例外——落一条 | 24 | * 去重跳过;{@code internalUserTurn=true} 时(反编造护栏重试的注入话术)例外——落一条 |
| 26 | * {@code internal} 标记的用户事件,LLM 可见、前端历史不显示。模型消息(tool_call/tool_result/ai) | 25 | * {@code internal} 标记的用户事件,LLM 可见、前端历史不显示。模型消息(tool_call/tool_result/ai) |
| 27 | * 只由本类落账。 | 26 | * 只由本类落账。 |
| 27 | + * | ||
| 28 | + * <p>tool_call 事件存**中立格式** {@code calls=[{id,name,args}]}(脱 LangChain4j 序列化耦合), | ||
| 29 | + * 旧版 {@code payload} 载荷投影层仍可读。 | ||
| 28 | */ | 30 | */ |
| 29 | public class EventLogChatMemory implements ChatMemory { | 31 | public class EventLogChatMemory implements ChatMemory { |
| 30 | 32 | ||
| @@ -33,23 +35,23 @@ public class EventLogChatMemory implements ChatMemory { | @@ -33,23 +35,23 @@ public class EventLogChatMemory implements ChatMemory { | ||
| 33 | private final String convId; | 35 | private final String convId; |
| 34 | private final LedgerService log; | 36 | private final LedgerService log; |
| 35 | private final EventProjectionService projection; | 37 | private final EventProjectionService projection; |
| 36 | - private final int charBudget; | 38 | + private final AgentIdentity identity; |
| 37 | private final boolean internalUserTurn; | 39 | private final boolean internalUserTurn; |
| 38 | 40 | ||
| 39 | - /** system prompt 每次由 provider 提供,不落日志(保持日志纯业务事件)。 */ | 41 | + /** system prompt 每次由 provider 提供,不落账(保持账本纯业务事件)。 */ |
| 40 | private volatile String systemText; | 42 | private volatile String systemText; |
| 41 | /** | 43 | /** |
| 42 | - * 本轮实际喂给模型的用户文本(原话 + 编排层附加的 grounding/状态后缀)。日志只存原话; | 44 | + * 本轮实际喂给模型的用户文本(原话 + 编排层附加的后缀)。账本只存原话; |
| 43 | * 投影时把当前轮用户消息替换为它。P2 编排层不再加后缀后,此字段恒空。 | 45 | * 投影时把当前轮用户消息替换为它。P2 编排层不再加后缀后,此字段恒空。 |
| 44 | */ | 46 | */ |
| 45 | private volatile String currentTurnUserText; | 47 | private volatile String currentTurnUserText; |
| 46 | 48 | ||
| 47 | public EventLogChatMemory(String convId, LedgerService log, EventProjectionService projection, | 49 | public EventLogChatMemory(String convId, LedgerService log, EventProjectionService projection, |
| 48 | - int charBudget, boolean internalUserTurn) { | 50 | + AgentIdentity identity, boolean internalUserTurn) { |
| 49 | this.convId = convId; | 51 | this.convId = convId; |
| 50 | this.log = log; | 52 | this.log = log; |
| 51 | this.projection = projection; | 53 | this.projection = projection; |
| 52 | - this.charBudget = charBudget; | 54 | + this.identity = identity; |
| 53 | this.internalUserTurn = internalUserTurn; | 55 | this.internalUserTurn = internalUserTurn; |
| 54 | } | 56 | } |
| 55 | 57 | ||
| @@ -75,22 +77,28 @@ public class EventLogChatMemory implements ChatMemory { | @@ -75,22 +77,28 @@ public class EventLogChatMemory implements ChatMemory { | ||
| 75 | if (internalUserTurn) { | 77 | if (internalUserTurn) { |
| 76 | data.put("internal", true); | 78 | data.put("internal", true); |
| 77 | } | 79 | } |
| 78 | - log.append(convId, "user", data); | 80 | + log.append(convId, "user", data, identity); |
| 79 | return; | 81 | return; |
| 80 | } | 82 | } |
| 81 | if (m instanceof AiMessage am) { | 83 | if (m instanceof AiMessage am) { |
| 82 | if (am.hasToolExecutionRequests()) { | 84 | if (am.hasToolExecutionRequests()) { |
| 83 | Map<String, Object> data = new LinkedHashMap<>(); | 85 | 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 | data.put("text", am.text() == null ? "" : am.text()); |
| 87 | + List<Map<String, Object>> calls = new ArrayList<>(); | ||
| 86 | List<String> tools = new ArrayList<>(); | 88 | List<String> tools = new ArrayList<>(); |
| 87 | for (ToolExecutionRequest r : am.toolExecutionRequests()) { | 89 | for (ToolExecutionRequest r : am.toolExecutionRequests()) { |
| 90 | + Map<String, Object> c = new LinkedHashMap<>(); | ||
| 91 | + c.put("id", r.id() == null ? "" : r.id()); | ||
| 92 | + c.put("name", r.name() == null ? "" : r.name()); | ||
| 93 | + c.put("args", r.arguments() == null ? "" : r.arguments()); | ||
| 94 | + calls.add(c); | ||
| 88 | tools.add(r.name()); | 95 | tools.add(r.name()); |
| 89 | } | 96 | } |
| 97 | + data.put("calls", calls); | ||
| 90 | data.put("tools", tools); | 98 | data.put("tools", tools); |
| 91 | - log.append(convId, "tool_call", data); | 99 | + log.append(convId, "tool_call", data, identity); |
| 92 | } else if (am.text() != null && !am.text().isBlank()) { | 100 | } else if (am.text() != null && !am.text().isBlank()) { |
| 93 | - log.append(convId, "ai", Map.of("text", am.text())); | 101 | + log.append(convId, "ai", Map.of("text", am.text()), identity); |
| 94 | } | 102 | } |
| 95 | return; | 103 | return; |
| 96 | } | 104 | } |
| @@ -101,13 +109,13 @@ public class EventLogChatMemory implements ChatMemory { | @@ -101,13 +109,13 @@ public class EventLogChatMemory implements ChatMemory { | ||
| 101 | data.put("name", tr.toolName() == null ? "" : tr.toolName()); | 109 | data.put("name", tr.toolName() == null ? "" : tr.toolName()); |
| 102 | data.put("text", text); | 110 | data.put("text", text); |
| 103 | data.put("digest", digest(text)); | 111 | data.put("digest", digest(text)); |
| 104 | - log.append(convId, "tool_result", data); | 112 | + log.append(convId, "tool_result", data, identity); |
| 105 | } | 113 | } |
| 106 | } | 114 | } |
| 107 | 115 | ||
| 108 | /** | 116 | /** |
| 109 | * 控制器已把本轮用户**原话**落账(最近的用户事件是喂给模型文本的前缀、且其后无模型事件) | 117 | * 控制器已把本轮用户**原话**落账(最近的用户事件是喂给模型文本的前缀、且其后无模型事件) |
| 110 | - * → 跳过,避免重复。编排层可能在原话后附加 grounding/状态后缀,故用前缀而非全等判定。 | 118 | + * → 跳过,避免重复。编排层可能在原话后附加后缀,故用前缀而非全等判定。 |
| 111 | */ | 119 | */ |
| 112 | private boolean alreadyLoggedPrefixOf(String text) { | 120 | private boolean alreadyLoggedPrefixOf(String text) { |
| 113 | List<Map<String, Object>> tail = log.events(convId, 6); | 121 | List<Map<String, Object>> tail = log.events(convId, 6); |
| @@ -120,7 +128,7 @@ public class EventLogChatMemory implements ChatMemory { | @@ -120,7 +128,7 @@ public class EventLogChatMemory implements ChatMemory { | ||
| 120 | case "form_submit", "ai", "tool_call", "tool_result", "assistant": | 128 | case "form_submit", "ai", "tool_call", "tool_result", "assistant": |
| 121 | return false; | 129 | return false; |
| 122 | default: | 130 | default: |
| 123 | - // confirm/proposal 等按钮事件可能插在中间,继续往前找 | 131 | + // queued/confirm 等按钮事件可能插在中间,继续往前找 |
| 124 | } | 132 | } |
| 125 | } | 133 | } |
| 126 | return false; | 134 | return false; |
| @@ -128,8 +136,7 @@ public class EventLogChatMemory implements ChatMemory { | @@ -128,8 +136,7 @@ public class EventLogChatMemory implements ChatMemory { | ||
| 128 | 136 | ||
| 129 | @Override | 137 | @Override |
| 130 | public List<ChatMessage> messages() { | 138 | public List<ChatMessage> messages() { |
| 131 | - List<ChatMessage> out = projection.project( | ||
| 132 | - systemText, log.events(convId, LLM_EVENT_WINDOW), charBudget); | 139 | + List<ChatMessage> out = projection.project(systemText, log.events(convId, LLM_EVENT_WINDOW)); |
| 133 | String full = currentTurnUserText; | 140 | String full = currentTurnUserText; |
| 134 | if (full != null) { | 141 | if (full != null) { |
| 135 | // 当前轮用户消息换成实际喂给模型的完整文本(原话+编排后缀) | 142 | // 当前轮用户消息换成实际喂给模型的完整文本(原话+编排后缀) |
| @@ -144,7 +151,7 @@ public class EventLogChatMemory implements ChatMemory { | @@ -144,7 +151,7 @@ public class EventLogChatMemory implements ChatMemory { | ||
| 144 | return out; | 151 | return out; |
| 145 | } | 152 | } |
| 146 | 153 | ||
| 147 | - /** 日志是唯一事实源,不因模型环异常清史;删除会话走 ConversationService 级联。 */ | 154 | + /** 账本是唯一事实源,不因模型环异常清史;删除会话走 ConversationService 级联。 */ |
| 148 | @Override | 155 | @Override |
| 149 | public void clear() { | 156 | public void clear() { |
| 150 | } | 157 | } |
src/main/java/com/xly/config/AgentFactory.java
| @@ -91,9 +91,9 @@ public class AgentFactory { | @@ -91,9 +91,9 @@ public class AgentFactory { | ||
| 91 | .streamingChatModel(streamingModel) | 91 | .streamingChatModel(streamingModel) |
| 92 | .tools(tools) | 92 | .tools(tools) |
| 93 | .maxSequentialToolsInvocations(8) // 循环护栏:防止 askUser/工具无限自我循环 | 93 | .maxSequentialToolsInvocations(8) // 循环护栏:防止 askUser/工具无限自我循环 |
| 94 | - // 事件日志记忆:逐条 append 原子落账,读取为四段式投影(见 EventLogChatMemory) | 94 | + // 事件账本记忆:逐条 append 原子落账,读取为四段式 token 记账投影(见 EventLogChatMemory) |
| 95 | .chatMemoryProvider(memoryId -> new EventLogChatMemory( | 95 | .chatMemoryProvider(memoryId -> new EventLogChatMemory( |
| 96 | - String.valueOf(memoryId), ledger, projection, 6000, internalUserTurn)) | 96 | + String.valueOf(memoryId), ledger, projection, identity, internalUserTurn)) |
| 97 | .systemMessageProvider(memoryId -> systemPromptService.prompt()) | 97 | .systemMessageProvider(memoryId -> systemPromptService.prompt()) |
| 98 | .build(); | 98 | .build(); |
| 99 | } | 99 | } |
src/main/java/com/xly/config/TracingChatModelListener.java
| @@ -53,12 +53,21 @@ public class TracingChatModelListener implements ChatModelListener { | @@ -53,12 +53,21 @@ public class TracingChatModelListener implements ChatModelListener { | ||
| 53 | this.mapper = mapper; | 53 | this.mapper = mapper; |
| 54 | } | 54 | } |
| 55 | 55 | ||
| 56 | + @org.springframework.beans.factory.annotation.Value("${llm.context-length:16384}") | ||
| 57 | + private int contextLength; | ||
| 58 | + | ||
| 56 | @Override | 59 | @Override |
| 57 | public void onRequest(ChatModelRequestContext ctx) { | 60 | public void onRequest(ChatModelRequestContext ctx) { |
| 58 | ctx.attributes().put("t0", System.nanoTime()); | 61 | ctx.attributes().put("t0", System.nanoTime()); |
| 59 | ctx.attributes().put("startTs", Instant.now().toString()); | 62 | ctx.attributes().put("startTs", Instant.now().toString()); |
| 60 | String model = ctx.chatRequest() == null ? null : ctx.chatRequest().modelName(); | 63 | String model = ctx.chatRequest() == null ? null : ctx.chatRequest().modelName(); |
| 61 | ctx.attributes().put("model", model == null ? "unknown" : model); | 64 | ctx.attributes().put("model", model == null ? "unknown" : model); |
| 65 | + try { // 校准回路:记下本地保守估算,onResponse 时与 prompt_eval_count 对账 | ||
| 66 | + if (ctx.chatRequest() != null && ctx.chatRequest().messages() != null) { | ||
| 67 | + ctx.attributes().put("estIn", com.xly.service.TokenEstimator.estimate(ctx.chatRequest().messages())); | ||
| 68 | + } | ||
| 69 | + } catch (Exception ignore) { | ||
| 70 | + } | ||
| 62 | } | 71 | } |
| 63 | 72 | ||
| 64 | @Override | 73 | @Override |
| @@ -73,10 +82,30 @@ public class TracingChatModelListener implements ChatModelListener { | @@ -73,10 +82,30 @@ public class TracingChatModelListener implements ChatModelListener { | ||
| 73 | } | 82 | } |
| 74 | } catch (Exception ignore) { | 83 | } catch (Exception ignore) { |
| 75 | } | 84 | } |
| 85 | + calibrate(ctx.attributes().get("estIn"), in, out); | ||
| 76 | record(String.valueOf(ctx.attributes().get("model")), ctx.attributes().get("t0"), | 86 | record(String.valueOf(ctx.attributes().get("model")), ctx.attributes().get("t0"), |
| 77 | String.valueOf(ctx.attributes().get("startTs")), in, out, null); | 87 | String.valueOf(ctx.attributes().get("startTs")), in, out, null); |
| 78 | } | 88 | } |
| 79 | 89 | ||
| 90 | + /** | ||
| 91 | + * token 估算校准回路:prompt_eval_count(响应免费自带)vs 本地保守估算。 | ||
| 92 | + * 实际 > 估算 = 估算器不够保守(截头风险);实际+输出逼近 num_ctx = 很可能已被 Ollama 静默截头,都告警。 | ||
| 93 | + */ | ||
| 94 | + private void calibrate(Object est, Integer actualIn, Integer actualOut) { | ||
| 95 | + if (!(est instanceof Integer e) || actualIn == null || actualIn <= 0) { | ||
| 96 | + return; | ||
| 97 | + } | ||
| 98 | + double ratio = actualIn / (double) e; | ||
| 99 | + log.info("LLM token calib est={} actual={} ratio={}", e, actualIn, String.format("%.2f", ratio)); | ||
| 100 | + if (actualIn > e) { | ||
| 101 | + log.warn("LLM token 估算偏低(est={} < actual={})——保守系数不足,存在被 num_ctx 截头的风险", e, actualIn); | ||
| 102 | + } | ||
| 103 | + int total = actualIn + (actualOut == null ? 0 : actualOut); | ||
| 104 | + if (total > contextLength * 0.95) { | ||
| 105 | + log.warn("LLM 上下文逼近 num_ctx={}(in+out={}),Ollama 可能已从最前静默截断——请检查投影预算", contextLength, total); | ||
| 106 | + } | ||
| 107 | + } | ||
| 108 | + | ||
| 80 | @Override | 109 | @Override |
| 81 | public void onError(ChatModelErrorContext ctx) { | 110 | public void onError(ChatModelErrorContext ctx) { |
| 82 | Throwable e = ctx.error(); | 111 | Throwable e = ctx.error(); |
src/main/java/com/xly/service/AuthzService.java
| @@ -64,7 +64,7 @@ public class AuthzService { | @@ -64,7 +64,7 @@ public class AuthzService { | ||
| 64 | String subsidiaryId = w.path("sSubsidiaryId").asText(""); | 64 | String subsidiaryId = w.path("sSubsidiaryId").asText(""); |
| 65 | String userType = w.path("sType").asText(""); | 65 | String userType = w.path("sType").asText(""); |
| 66 | Set<String> granted = grantedIds(userId, userType, brandsId, subsidiaryId); | 66 | Set<String> granted = grantedIds(userId, userType, brandsId, subsidiaryId); |
| 67 | - return new com.xly.agent.AgentIdentity(token, userId, brandsId, granted); | 67 | + return new com.xly.agent.AgentIdentity(token, userId, brandsId, subsidiaryId, granted); |
| 68 | } | 68 | } |
| 69 | return erp.devLoginEnabled() ? devIdentity() : null; | 69 | return erp.devLoginEnabled() ? devIdentity() : null; |
| 70 | } | 70 | } |
| @@ -76,7 +76,7 @@ public class AuthzService { | @@ -76,7 +76,7 @@ public class AuthzService { | ||
| 76 | public com.xly.agent.AgentIdentity devIdentity() { | 76 | public com.xly.agent.AgentIdentity devIdentity() { |
| 77 | String uid = devUserIdOverride != null && !devUserIdOverride.isBlank() ? devUserIdOverride : resolveDevUserId(); | 77 | String uid = devUserIdOverride != null && !devUserIdOverride.isBlank() ? devUserIdOverride : resolveDevUserId(); |
| 78 | Set<String> granted = grantedIds(uid, devUserType, devBrand, devSub); | 78 | Set<String> granted = grantedIds(uid, devUserType, devBrand, devSub); |
| 79 | - return new com.xly.agent.AgentIdentity(null, uid, devBrand, granted); | 79 | + return new com.xly.agent.AgentIdentity(null, uid, devBrand, devSub, granted); |
| 80 | } | 80 | } |
| 81 | 81 | ||
| 82 | /** null = 全部(管理员);否则 = 有权的 id 集合。 */ | 82 | /** null = 全部(管理员);否则 = 有权的 id 集合。 */ |
src/main/java/com/xly/service/EventProjectionService.java
| @@ -2,123 +2,302 @@ package com.xly.service; | @@ -2,123 +2,302 @@ package com.xly.service; | ||
| 2 | 2 | ||
| 3 | import com.fasterxml.jackson.databind.JsonNode; | 3 | import com.fasterxml.jackson.databind.JsonNode; |
| 4 | import com.fasterxml.jackson.databind.ObjectMapper; | 4 | import com.fasterxml.jackson.databind.ObjectMapper; |
| 5 | +import dev.langchain4j.agent.tool.ToolExecutionRequest; | ||
| 5 | import dev.langchain4j.data.message.AiMessage; | 6 | import dev.langchain4j.data.message.AiMessage; |
| 6 | import dev.langchain4j.data.message.ChatMessage; | 7 | import dev.langchain4j.data.message.ChatMessage; |
| 7 | import dev.langchain4j.data.message.ChatMessageDeserializer; | 8 | import dev.langchain4j.data.message.ChatMessageDeserializer; |
| 8 | import dev.langchain4j.data.message.SystemMessage; | 9 | import dev.langchain4j.data.message.SystemMessage; |
| 9 | import dev.langchain4j.data.message.ToolExecutionResultMessage; | 10 | import dev.langchain4j.data.message.ToolExecutionResultMessage; |
| 10 | import dev.langchain4j.data.message.UserMessage; | 11 | import dev.langchain4j.data.message.UserMessage; |
| 12 | +import org.slf4j.Logger; | ||
| 13 | +import org.slf4j.LoggerFactory; | ||
| 14 | +import org.springframework.beans.factory.annotation.Value; | ||
| 11 | import org.springframework.stereotype.Service; | 15 | import org.springframework.stereotype.Service; |
| 12 | 16 | ||
| 13 | import java.util.ArrayList; | 17 | import java.util.ArrayList; |
| 14 | import java.util.LinkedHashMap; | 18 | import java.util.LinkedHashMap; |
| 15 | import java.util.List; | 19 | import java.util.List; |
| 16 | import java.util.Map; | 20 | import java.util.Map; |
| 21 | +import java.util.function.UnaryOperator; | ||
| 17 | 22 | ||
| 18 | /** | 23 | /** |
| 19 | - * 事件日志的**唯一**投影/渲染器 —— 前端历史与 LLM 上下文同源于 {@link LedgerService} 的事件流, | 24 | + * 事件账本的**唯一**投影/渲染器 —— 前端历史与 LLM 上下文同源于 {@link LedgerService} 的事件流, |
| 20 | * 文案在这一处生成,不再散落在各控制器里。 | 25 | * 文案在这一处生成,不再散落在各控制器里。 |
| 21 | * | 26 | * |
| 22 | - * <p><b>LLM 上下文四段式</b>({@link #project}): | 27 | + * <p><b>LLM 上下文四段式</b>({@link #project}),预算按 <b>token</b> 记账(本地保守估算 |
| 28 | + * {@link TokenEstimator},绝对值由 {@code llm.context-length}(镜像 Ollama 的 | ||
| 29 | + * OLLAMA_CONTEXT_LENGTH,经 OpenAI 兼容通道无法逐请求传 num_ctx)导出): | ||
| 23 | * <ol> | 30 | * <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> | 31 | + * <li>① 稳定 system prompt + ② 进行中流程卡 —— 永不让位;</li> |
| 32 | + * <li>④ 往事摘要区:预算外旧轮 → 确定性一行摘要(零模型调用);</li> | ||
| 33 | + * <li>⑤ 近期原文区:按**整轮**纳入,旧轮工具结果压 {@value #TOOL_DIGEST_LEN} 字;</li> | ||
| 34 | + * <li>当前轮:原样保真、工具配对不可破坏;当前轮自身超限时**轮内让位**——最早的工具结果 | ||
| 35 | + * 降为摘要(保最近 {@value #CURRENT_KEEP_FULL} 个全文),单条结果超硬顶截断。</li> | ||
| 30 | * </ol> | 36 | * </ol> |
| 37 | + * 组装后做**发送前自检**:总 token 超预算 → ⑤ 最旧轮退入 ④、再裁 ④ 最旧行;①②与当前轮不动。 | ||
| 38 | + * 绝不把超限上下文交给 Ollama 静默截头(它从最前截、① 先死)。 | ||
| 31 | */ | 39 | */ |
| 32 | @Service | 40 | @Service |
| 33 | public class EventProjectionService { | 41 | public class EventProjectionService { |
| 34 | 42 | ||
| 43 | + private static final Logger log = LoggerFactory.getLogger(EventProjectionService.class); | ||
| 44 | + | ||
| 35 | public static final int TOOL_DIGEST_LEN = 120; | 45 | public static final int TOOL_DIGEST_LEN = 120; |
| 36 | private static final int DIGEST_TURNS_MAX = 40; | 46 | private static final int DIGEST_TURNS_MAX = 40; |
| 37 | private static final int DIGEST_LINE_LEN = 90; | 47 | private static final int DIGEST_LINE_LEN = 90; |
| 38 | 48 | ||
| 49 | + /** 模型输出预留(num_ctx 覆盖 prompt+生成)。 */ | ||
| 50 | + private static final int OUTPUT_RESERVE = 1024; | ||
| 51 | + /** 工具 schema(随每次请求发送、不在 messages 里)的估算开销。 */ | ||
| 52 | + private static final int TOOLS_OVERHEAD = 2000; | ||
| 53 | + /** 当前轮增长的乐观小额预留(每次工具往返都会重投影,超出时反应式收缩⑤)。 */ | ||
| 54 | + private static final int TURN_RESERVE = 512; | ||
| 55 | + /** ④ 摘要区 token 上限。 */ | ||
| 56 | + private static final int DIGEST_BUDGET = 800; | ||
| 57 | + /** 当前轮里保全文的最近工具结果个数(轮内让位时更早的降摘要)。 */ | ||
| 58 | + private static final int CURRENT_KEEP_FULL = 2; | ||
| 59 | + /** 当前轮单条工具结果硬顶(token),超出截断。 */ | ||
| 60 | + private static final int SINGLE_RESULT_CAP = 2400; | ||
| 61 | + | ||
| 39 | private final ObjectMapper mapper; | 62 | private final ObjectMapper mapper; |
| 63 | + private final int contextLength; | ||
| 40 | 64 | ||
| 41 | - public EventProjectionService(ObjectMapper mapper) { | 65 | + /** opId → 人类可读状态行(Phase C:流程卡只读展示 ai_op_queue.sStatus)。可空(测试/未装配)。 */ |
| 66 | + private volatile UnaryOperator<String> opStatusLookup; | ||
| 67 | + | ||
| 68 | + public EventProjectionService(ObjectMapper mapper, @Value("${llm.context-length:16384}") int contextLength) { | ||
| 42 | this.mapper = mapper; | 69 | this.mapper = mapper; |
| 70 | + this.contextLength = contextLength; | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + public EventProjectionService(ObjectMapper mapper) { | ||
| 74 | + this(mapper, 16384); | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + public void setOpStatusLookup(UnaryOperator<String> lookup) { | ||
| 78 | + this.opStatusLookup = lookup; | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + /** 输入 prompt 的总预算(token)。 */ | ||
| 82 | + int promptBudget() { | ||
| 83 | + return Math.max(2048, contextLength - OUTPUT_RESERVE - TOOLS_OVERHEAD); | ||
| 43 | } | 84 | } |
| 44 | 85 | ||
| 45 | // ---------------------------------------------------------------- LLM 投影 | 86 | // ---------------------------------------------------------------- LLM 投影 |
| 46 | 87 | ||
| 47 | - /** 四段式 LLM 上下文。systemText 为空时不出 system 消息(如测试)。 */ | ||
| 48 | - public List<ChatMessage> project(String systemText, List<Map<String, Object>> events, int charBudget) { | 88 | + /** 四段式 LLM 上下文(token 记账)。systemText 为空时不出 system 消息(如测试)。 */ |
| 89 | + public List<ChatMessage> project(String systemText, List<Map<String, Object>> events) { | ||
| 90 | + int budget = promptBudget(); | ||
| 49 | List<List<Map<String, Object>>> turns = groupTurns(events); | 91 | List<List<Map<String, Object>>> turns = groupTurns(events); |
| 50 | int current = turns.size() - 1; | 92 | int current = turns.size() - 1; |
| 51 | 93 | ||
| 52 | - // ④ 近期原文区:从最新往回按整轮纳入;当前轮必进且不计预算 | ||
| 53 | - int firstVerbatim = current; | 94 | + // ①② system + 流程卡(永不让位) |
| 95 | + SystemMessage sys = null; | ||
| 96 | + int sysEst = 0; | ||
| 97 | + if (systemText != null && !systemText.isBlank()) { | ||
| 98 | + String card = activeCard(events); | ||
| 99 | + sys = SystemMessage.from(card == null ? systemText | ||
| 100 | + : systemText + "\n\n【进行中的流程】\n" + card); | ||
| 101 | + sysEst = TokenEstimator.estimate(sys); | ||
| 102 | + } | ||
| 103 | + | ||
| 104 | + // 当前轮:原样保真;自身超限时轮内让位 | ||
| 105 | + List<ChatMessage> currentMsgs = new ArrayList<>(); | ||
| 106 | + int curEst = 0; | ||
| 107 | + if (current >= 0) { | ||
| 108 | + int curAllowance = budget - sysEst - TURN_RESERVE; | ||
| 109 | + currentMsgs = renderCurrentTurn(turns.get(current), curAllowance); | ||
| 110 | + curEst = TokenEstimator.estimate(currentMsgs); | ||
| 111 | + } | ||
| 112 | + | ||
| 113 | + // ⑤ 近期原文区:从最新旧轮往回按整轮纳入 | ||
| 114 | + List<List<ChatMessage>> oldRendered = new ArrayList<>(); // index 对齐 turns[0..current-1] | ||
| 115 | + int[] oldEst = new int[Math.max(0, current)]; | ||
| 116 | + for (int i = 0; i < current; i++) { | ||
| 117 | + List<ChatMessage> ms = new ArrayList<>(); | ||
| 118 | + appendTurnMessages(ms, turns.get(i), false); | ||
| 119 | + oldRendered.add(ms); | ||
| 120 | + oldEst[i] = TokenEstimator.estimate(ms); | ||
| 121 | + } | ||
| 122 | + int remaining = budget - sysEst - curEst - TURN_RESERVE; | ||
| 123 | + int firstVerbatim = fillRecent(oldEst, current, remaining, 0); | ||
| 124 | + if (firstVerbatim > 0) { // 有轮进摘要区 → 给 ④ 留预算后重算 | ||
| 125 | + firstVerbatim = fillRecent(oldEst, current, remaining, DIGEST_BUDGET); | ||
| 126 | + } | ||
| 127 | + | ||
| 128 | + // ④ 摘要行 | ||
| 129 | + List<String> digestLines = new ArrayList<>(); | ||
| 130 | + int digestFrom = Math.max(0, firstVerbatim - DIGEST_TURNS_MAX); | ||
| 131 | + for (int i = digestFrom; i < firstVerbatim; i++) { | ||
| 132 | + String line = digestLine(turns.get(i)); | ||
| 133 | + if (!line.isBlank()) { | ||
| 134 | + digestLines.add(line); | ||
| 135 | + } | ||
| 136 | + } | ||
| 137 | + int omitted = digestFrom; | ||
| 138 | + | ||
| 139 | + // 组装 + 发送前自检(⑤ 最旧退 ④ → 裁 ④ 最旧;①② 与当前轮不动) | ||
| 140 | + while (true) { | ||
| 141 | + List<ChatMessage> out = assemble(sys, digestLines, omitted, oldRendered, firstVerbatim, current, currentMsgs); | ||
| 142 | + int total = TokenEstimator.estimate(out); | ||
| 143 | + if (total <= budget) { | ||
| 144 | + return out; | ||
| 145 | + } | ||
| 146 | + if (firstVerbatim < current) { | ||
| 147 | + String line = digestLine(turns.get(firstVerbatim)); | ||
| 148 | + if (!line.isBlank()) { | ||
| 149 | + digestLines.add(line); | ||
| 150 | + } | ||
| 151 | + firstVerbatim++; | ||
| 152 | + continue; | ||
| 153 | + } | ||
| 154 | + if (!digestLines.isEmpty()) { | ||
| 155 | + digestLines.remove(0); | ||
| 156 | + omitted++; | ||
| 157 | + continue; | ||
| 158 | + } | ||
| 159 | + log.warn("projection over budget even after shrink: total≈{}t budget={}t (当前轮过大)", total, budget); | ||
| 160 | + return out; | ||
| 161 | + } | ||
| 162 | + } | ||
| 163 | + | ||
| 164 | + /** ⑤ 从最新旧轮往回装,返回 firstVerbatim(第一条进原文区的旧轮下标;current 表示一条都装不下)。 */ | ||
| 165 | + private static int fillRecent(int[] oldEst, int current, int remaining, int digestAllowance) { | ||
| 166 | + int budget = remaining - digestAllowance; | ||
| 54 | int used = 0; | 167 | int used = 0; |
| 168 | + int first = current; | ||
| 55 | for (int i = current - 1; i >= 0; i--) { | 169 | 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) { | 170 | + if (used + oldEst[i] > budget) { |
| 61 | break; | 171 | break; |
| 62 | } | 172 | } |
| 63 | - used += size; | ||
| 64 | - firstVerbatim = i; | 173 | + used += oldEst[i]; |
| 174 | + first = i; | ||
| 65 | } | 175 | } |
| 176 | + return first; | ||
| 177 | + } | ||
| 66 | 178 | ||
| 179 | + private List<ChatMessage> assemble(SystemMessage sys, List<String> digestLines, int omitted, | ||
| 180 | + List<List<ChatMessage>> oldRendered, int firstVerbatim, int current, | ||
| 181 | + List<ChatMessage> currentMsgs) { | ||
| 67 | List<ChatMessage> out = new ArrayList<>(); | 182 | 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)); | 183 | + if (sys != null) { |
| 184 | + out.add(sys); | ||
| 72 | } | 185 | } |
| 73 | - | ||
| 74 | - // ③ 往事摘要区 | ||
| 75 | - if (firstVerbatim > 0) { | 186 | + if (!digestLines.isEmpty() || omitted > 0) { |
| 76 | StringBuilder sb = new StringBuilder("【更早对话摘要(自动截断,仅供参考)】"); | 187 | 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(" 轮已省略)"); | 188 | + if (omitted > 0) { |
| 189 | + sb.append("\n(更早 ").append(omitted).append(" 轮已省略)"); | ||
| 80 | } | 190 | } |
| 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 | - } | 191 | + for (String line : digestLines) { |
| 192 | + sb.append("\n- ").append(line); | ||
| 86 | } | 193 | } |
| 87 | out.add(UserMessage.from(sb.toString())); | 194 | out.add(UserMessage.from(sb.toString())); |
| 88 | } | 195 | } |
| 196 | + for (int i = firstVerbatim; i < current; i++) { | ||
| 197 | + out.addAll(oldRendered.get(i)); | ||
| 198 | + } | ||
| 199 | + out.addAll(currentMsgs); | ||
| 200 | + return out; | ||
| 201 | + } | ||
| 89 | 202 | ||
| 90 | - for (int i = firstVerbatim; i < turns.size(); i++) { | ||
| 91 | - appendTurnMessages(out, turns.get(i), i == current); | 203 | + /** |
| 204 | + * 当前轮渲染:原样保真;总量超 allowance 时**轮内让位**——最早的工具结果先降 | ||
| 205 | + * {@value #TOOL_DIGEST_LEN} 字摘要(保最近 {@value #CURRENT_KEEP_FULL} 个全文,仍超则只保 1 个)。 | ||
| 206 | + * 工具调用/结果配对永不破坏,单条结果超 {@value #SINGLE_RESULT_CAP}t 截断。 | ||
| 207 | + */ | ||
| 208 | + private List<ChatMessage> renderCurrentTurn(List<Map<String, Object>> turn, int allowance) { | ||
| 209 | + for (int keepFull = CURRENT_KEEP_FULL; keepFull >= 0; keepFull--) { | ||
| 210 | + List<ChatMessage> ms = renderCurrentWith(turn, keepFull); | ||
| 211 | + if (TokenEstimator.estimate(ms) <= allowance || keepFull == 0) { | ||
| 212 | + return ms; | ||
| 213 | + } | ||
| 214 | + } | ||
| 215 | + return List.of(); | ||
| 216 | + } | ||
| 217 | + | ||
| 218 | + private List<ChatMessage> renderCurrentWith(List<Map<String, Object>> turn, int keepFull) { | ||
| 219 | + int results = 0; | ||
| 220 | + for (Map<String, Object> ev : turn) { | ||
| 221 | + if ("tool_result".equals(str(ev.get("type")))) { | ||
| 222 | + results++; | ||
| 223 | + } | ||
| 224 | + } | ||
| 225 | + int demote = Math.max(0, results - keepFull); | ||
| 226 | + List<ChatMessage> out = new ArrayList<>(); | ||
| 227 | + int seen = 0; | ||
| 228 | + for (Map<String, Object> ev : turn) { | ||
| 229 | + if ("tool_result".equals(str(ev.get("type")))) { | ||
| 230 | + seen++; | ||
| 231 | + String text = seen <= demote ? str(ev.get("digest")) : capped(str(ev.get("text"))); | ||
| 232 | + out.add(ToolExecutionResultMessage.from(str(ev.get("tcId")), str(ev.get("name")), text)); | ||
| 233 | + } else { | ||
| 234 | + appendOneMessage(out, ev, true); | ||
| 235 | + } | ||
| 92 | } | 236 | } |
| 93 | return out; | 237 | return out; |
| 94 | } | 238 | } |
| 95 | 239 | ||
| 240 | + private static String capped(String text) { | ||
| 241 | + if (TokenEstimator.estimate(text) <= SINGLE_RESULT_CAP) { | ||
| 242 | + return text; | ||
| 243 | + } | ||
| 244 | + // 按估算规则回推一个安全字符数(全 CJK 最坏情形 = 1 字 1 token) | ||
| 245 | + return text.substring(0, Math.min(text.length(), SINGLE_RESULT_CAP)) + "……[超长已截断]"; | ||
| 246 | + } | ||
| 247 | + | ||
| 96 | /** 一轮 → LLM 消息。当前轮工具结果全文保真;旧轮压一行摘要。 */ | 248 | /** 一轮 → LLM 消息。当前轮工具结果全文保真;旧轮压一行摘要。 */ |
| 97 | private void appendTurnMessages(List<ChatMessage> out, List<Map<String, Object>> turn, boolean isCurrent) { | 249 | private void appendTurnMessages(List<ChatMessage> out, List<Map<String, Object>> turn, boolean isCurrent) { |
| 98 | for (Map<String, Object> ev : turn) { | 250 | 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 | - } | 251 | + if ("tool_result".equals(str(ev.get("type")))) { |
| 252 | + String text = isCurrent ? str(ev.get("text")) : str(ev.get("digest")); | ||
| 253 | + out.add(ToolExecutionResultMessage.from(str(ev.get("tcId")), str(ev.get("name")), text)); | ||
| 254 | + } else { | ||
| 255 | + appendOneMessage(out, ev, isCurrent); | ||
| 256 | + } | ||
| 257 | + } | ||
| 258 | + } | ||
| 259 | + | ||
| 260 | + private void appendOneMessage(List<ChatMessage> out, Map<String, Object> ev, boolean isCurrent) { | ||
| 261 | + String type = str(ev.get("type")); | ||
| 262 | + switch (type) { | ||
| 263 | + case "user" -> out.add(UserMessage.from(str(ev.get("text")))); | ||
| 264 | + case "form_submit" -> out.add(UserMessage.from(renderFormSubmit(ev))); | ||
| 265 | + case "ai", "assistant", "clarify" -> addAi(out, str(ev.get("text"))); | ||
| 266 | + case "tool_call" -> { | ||
| 267 | + ChatMessage m = toolCallMessage(ev); | ||
| 268 | + if (m != null) { | ||
| 269 | + out.add(m); | ||
| 109 | } | 270 | } |
| 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)); | 271 | + } |
| 272 | + case "question" -> addAi(out, str(ev.get("question"))); | ||
| 273 | + case "form" -> addAi(out, "已为「" + str(ev.get("entity")) + "」弹出新建表单,等待用户填写提交。"); | ||
| 274 | + case "queued" -> addAi(out, renderQueued(ev)); | ||
| 275 | + case "proposal" -> addAi(out, "已生成待确认提议:" + str(ev.get("summary")) + "(等待用户点确认/取消,尚未执行)"); | ||
| 276 | + case "confirm", "cancel" -> addAi(out, renderOutcome(ev)); | ||
| 277 | + default -> { } // tool(旧版摘要)/skill_active/skill_done 不进正文(skill 由流程卡承载) | ||
| 278 | + } | ||
| 279 | + } | ||
| 280 | + | ||
| 281 | + /** tool_call 事件 → AiMessage:优先中立格式 calls=[{id,name,args}],回退旧版 LangChain4j 序列化载荷。 */ | ||
| 282 | + private ChatMessage toolCallMessage(Map<String, Object> ev) { | ||
| 283 | + Object calls = ev.get("calls"); | ||
| 284 | + if (calls instanceof List<?> list && !list.isEmpty()) { | ||
| 285 | + List<ToolExecutionRequest> reqs = new ArrayList<>(); | ||
| 286 | + for (Object o : list) { | ||
| 287 | + if (o instanceof Map<?, ?> m) { | ||
| 288 | + reqs.add(ToolExecutionRequest.builder() | ||
| 289 | + .id(str(m.get("id"))) | ||
| 290 | + .name(str(m.get("name"))) | ||
| 291 | + .arguments(str(m.get("args"))) | ||
| 292 | + .build()); | ||
| 114 | } | 293 | } |
| 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 由流程卡承载) | 294 | + } |
| 295 | + if (!reqs.isEmpty()) { | ||
| 296 | + String text = str(ev.get("text")); | ||
| 297 | + return text.isBlank() ? AiMessage.from(reqs) : AiMessage.from(text, reqs); | ||
| 120 | } | 298 | } |
| 121 | } | 299 | } |
| 300 | + return fromPayload(str(ev.get("payload"))); | ||
| 122 | } | 301 | } |
| 123 | 302 | ||
| 124 | // ---------------------------------------------------------------- 前端历史投影 | 303 | // ---------------------------------------------------------------- 前端历史投影 |
| @@ -138,6 +317,7 @@ public class EventProjectionService { | @@ -138,6 +317,7 @@ public class EventProjectionService { | ||
| 138 | case "ai", "assistant", "clarify" -> addHist(out, str(ev.get("text"))); | 317 | case "ai", "assistant", "clarify" -> addHist(out, str(ev.get("text"))); |
| 139 | case "question" -> addHist(out, str(ev.get("question"))); | 318 | case "question" -> addHist(out, str(ev.get("question"))); |
| 140 | case "form" -> addHist(out, "【表单】新建" + str(ev.get("entity")) + ":" + str(ev.get("message"))); | 319 | case "form" -> addHist(out, "【表单】新建" + str(ev.get("entity")) + ":" + str(ev.get("message"))); |
| 320 | + case "queued" -> addHist(out, "【已提交待办】" + str(ev.get("description"))); | ||
| 141 | case "proposal" -> addHist(out, "【待确认】" + str(ev.get("summary"))); | 321 | case "proposal" -> addHist(out, "【待确认】" + str(ev.get("summary"))); |
| 142 | case "confirm" -> addHist(out, ("executed".equals(str(ev.get("status"))) ? "【已执行】" : "【执行失败】") | 322 | case "confirm" -> addHist(out, ("executed".equals(str(ev.get("status"))) ? "【已执行】" : "【执行失败】") |
| 143 | + str(ev.get("description")) + parenOr(str(ev.get("msg")))); | 323 | + str(ev.get("description")) + parenOr(str(ev.get("msg")))); |
| @@ -149,10 +329,11 @@ public class EventProjectionService { | @@ -149,10 +329,11 @@ public class EventProjectionService { | ||
| 149 | return out; | 329 | return out; |
| 150 | } | 330 | } |
| 151 | 331 | ||
| 152 | - /** agent 路径的 提问/表单/提议 不再单独落显示事件——从工具结果同源推导。 */ | 332 | + /** agent 路径的 提问/表单/预览 不再单独落显示事件——从工具结果同源推导。 */ |
| 153 | private String historyFromToolResult(Map<String, Object> ev) { | 333 | private String historyFromToolResult(Map<String, Object> ev) { |
| 154 | String name = str(ev.get("name")); | 334 | String name = str(ev.get("name")); |
| 155 | - if (!"askUser".equals(name) && !"collectForm".equals(name) && !"proposeWrite".equals(name)) { | 335 | + if (!"askUser".equals(name) && !"collectForm".equals(name) |
| 336 | + && !"previewChange".equals(name) && !"proposeWrite".equals(name)) { | ||
| 156 | return ""; | 337 | return ""; |
| 157 | } | 338 | } |
| 158 | try { | 339 | try { |
| @@ -163,6 +344,9 @@ public class EventProjectionService { | @@ -163,6 +344,9 @@ public class EventProjectionService { | ||
| 163 | if ("collectForm".equals(name) && "form_collect".equals(r.path("type").asText(""))) { | 344 | if ("collectForm".equals(name) && "form_collect".equals(r.path("type").asText(""))) { |
| 164 | return "【表单】新建" + r.path("entity").asText("") + ":" + r.path("message").asText(""); | 345 | return "【表单】新建" + r.path("entity").asText("") + ":" + r.path("message").asText(""); |
| 165 | } | 346 | } |
| 347 | + if ("previewChange".equals(name) && "change_preview".equals(r.path("type").asText(""))) { | ||
| 348 | + return "【预览】" + r.path("summary").asText(""); | ||
| 349 | + } | ||
| 166 | if ("proposeWrite".equals(name) && !r.path("opId").asText("").isBlank()) { | 350 | if ("proposeWrite".equals(name) && !r.path("opId").asText("").isBlank()) { |
| 167 | return "【待确认】" + r.path("summary").asText(""); | 351 | return "【待确认】" + r.path("summary").asText(""); |
| 168 | } | 352 | } |
| @@ -175,14 +359,13 @@ public class EventProjectionService { | @@ -175,14 +359,13 @@ public class EventProjectionService { | ||
| 175 | 359 | ||
| 176 | /** | 360 | /** |
| 177 | * 进行中流程卡:激活 skill 全文(最近一次 {@code skill_active},被 {@code skill_done} 或更新的 | 361 | * 进行中流程卡:激活 skill 全文(最近一次 {@code skill_active},被 {@code skill_done} 或更新的 |
| 178 | - * skill 覆盖前一直钉住)+ 在办单据状态(表单待提交 / 提议待确认;executed/cancelled/failed 即摘下)。 | 362 | + * skill 覆盖前一直钉住)+ 在办单据状态(表单待提交 / 预览待保存 / 已提交待办的只读进度)。 |
| 363 | + * {@code queued} 后技能线摘下(AI 侧流程到写入队列为止),待办行保留到下一个流程事件替换。 | ||
| 179 | * 两者皆无 → null。 | 364 | * 两者皆无 → null。 |
| 180 | */ | 365 | */ |
| 181 | public String activeCard(List<Map<String, Object>> events) { | 366 | public String activeCard(List<Map<String, Object>> events) { |
| 182 | String skillText = null; | 367 | String skillText = null; |
| 183 | - Map<String, Object> lastFlow = null; // form/collectForm/proposal/confirm/cancel/form_submit 中最近的一个 | ||
| 184 | - String proposalOpId = null; | ||
| 185 | - String proposalSummary = null; | 368 | + Map<String, Object> lastFlow = null; |
| 186 | 369 | ||
| 187 | for (Map<String, Object> ev : events) { | 370 | for (Map<String, Object> ev : events) { |
| 188 | String type = str(ev.get("type")); | 371 | String type = str(ev.get("type")); |
| @@ -190,34 +373,30 @@ public class EventProjectionService { | @@ -190,34 +373,30 @@ public class EventProjectionService { | ||
| 190 | case "skill_active" -> skillText = str(ev.get("text")); | 373 | case "skill_active" -> skillText = str(ev.get("text")); |
| 191 | case "skill_done" -> skillText = null; | 374 | case "skill_done" -> skillText = null; |
| 192 | case "form", "form_submit", "proposal" -> lastFlow = ev; | 375 | case "form", "form_submit", "proposal" -> lastFlow = ev; |
| 193 | - case "confirm", "cancel" -> { // 流程终结:单据线与技能线一起摘下 | 376 | + case "queued", "confirm", "cancel" -> { // AI 侧流程终结:技能线摘下 |
| 194 | lastFlow = ev; | 377 | lastFlow = ev; |
| 195 | skillText = null; | 378 | skillText = null; |
| 196 | } | 379 | } |
| 197 | case "tool_result" -> { | 380 | case "tool_result" -> { |
| 198 | - String name = str(ev.get("name")); | ||
| 199 | - if ("collectForm".equals(name) || "proposeWrite".equals(name)) { | ||
| 200 | - Map<String, Object> derived = deriveFlowEvent(ev); | ||
| 201 | - if (derived != null) { | ||
| 202 | - lastFlow = derived; | ||
| 203 | - } | 381 | + Map<String, Object> derived = deriveFlowEvent(ev); |
| 382 | + if (derived != null) { | ||
| 383 | + lastFlow = derived; | ||
| 204 | } | 384 | } |
| 205 | } | 385 | } |
| 206 | default -> { } | 386 | default -> { } |
| 207 | } | 387 | } |
| 208 | - if (lastFlow != null && "proposal".equals(str(lastFlow.get("type")))) { | ||
| 209 | - proposalOpId = str(lastFlow.get("opId")); | ||
| 210 | - proposalSummary = str(lastFlow.get("summary")); | ||
| 211 | - } | ||
| 212 | } | 388 | } |
| 213 | 389 | ||
| 214 | String docLine = null; | 390 | String docLine = null; |
| 215 | if (lastFlow != null) { | 391 | if (lastFlow != null) { |
| 216 | switch (str(lastFlow.get("type"))) { | 392 | switch (str(lastFlow.get("type"))) { |
| 217 | case "form" -> docLine = "已为「" + str(lastFlow.get("entity")) + "」弹出新建表单,等待用户填写提交。"; | 393 | case "form" -> docLine = "已为「" + str(lastFlow.get("entity")) + "」弹出新建表单,等待用户填写提交。"; |
| 218 | - case "proposal" -> docLine = "待确认提议:" + proposalSummary | ||
| 219 | - + "(opId=" + proposalOpId + ",用户点【确认】才会执行)。"; | ||
| 220 | - default -> { } // form_submit/confirm/cancel = 流程已推进/终结,卡摘下 | 394 | + case "preview" -> docLine = "已生成写操作预览卡:" + str(lastFlow.get("summary")) |
| 395 | + + "(等待用户点卡上按钮提交,**尚未写入**)。"; | ||
| 396 | + case "queued" -> docLine = renderQueuedLine(lastFlow); | ||
| 397 | + case "proposal" -> docLine = "待确认提议:" + str(lastFlow.get("summary")) | ||
| 398 | + + "(opId=" + str(lastFlow.get("opId")) + ",用户点【确认】才会执行)。"; | ||
| 399 | + default -> { } // form_submit/confirm/cancel = 流程已推进/终结 | ||
| 221 | } | 400 | } |
| 222 | } | 401 | } |
| 223 | 402 | ||
| @@ -237,17 +416,28 @@ public class EventProjectionService { | @@ -237,17 +416,28 @@ public class EventProjectionService { | ||
| 237 | return sb.toString(); | 416 | return sb.toString(); |
| 238 | } | 417 | } |
| 239 | 418 | ||
| 240 | - /** 工具结果 → 流程事件(collectForm 弹表单 / proposeWrite 出提议),供流程卡推导。 */ | 419 | + /** 工具结果 → 流程事件(collectForm 弹表单 / previewChange 出预览 / 旧版 proposeWrite),供流程卡推导。 */ |
| 241 | private Map<String, Object> deriveFlowEvent(Map<String, Object> ev) { | 420 | private Map<String, Object> deriveFlowEvent(Map<String, Object> ev) { |
| 421 | + String name = str(ev.get("name")); | ||
| 422 | + if (!"collectForm".equals(name) && !"previewChange".equals(name) && !"proposeWrite".equals(name)) { | ||
| 423 | + return null; | ||
| 424 | + } | ||
| 242 | try { | 425 | try { |
| 243 | JsonNode r = mapper.readTree(str(ev.get("text"))); | 426 | JsonNode r = mapper.readTree(str(ev.get("text"))); |
| 244 | - if ("collectForm".equals(str(ev.get("name"))) && "form_collect".equals(r.path("type").asText(""))) { | 427 | + if ("collectForm".equals(name) && "form_collect".equals(r.path("type").asText(""))) { |
| 245 | Map<String, Object> m = new LinkedHashMap<>(); | 428 | Map<String, Object> m = new LinkedHashMap<>(); |
| 246 | m.put("type", "form"); | 429 | m.put("type", "form"); |
| 247 | m.put("entity", r.path("entity").asText("")); | 430 | m.put("entity", r.path("entity").asText("")); |
| 248 | return m; | 431 | return m; |
| 249 | } | 432 | } |
| 250 | - if ("proposeWrite".equals(str(ev.get("name"))) && !r.path("opId").asText("").isBlank()) { | 433 | + if ("previewChange".equals(name) && "change_preview".equals(r.path("type").asText(""))) { |
| 434 | + Map<String, Object> m = new LinkedHashMap<>(); | ||
| 435 | + m.put("type", "preview"); | ||
| 436 | + m.put("previewId", r.path("previewId").asText("")); | ||
| 437 | + m.put("summary", r.path("summary").asText("")); | ||
| 438 | + return m; | ||
| 439 | + } | ||
| 440 | + if ("proposeWrite".equals(name) && !r.path("opId").asText("").isBlank()) { | ||
| 251 | Map<String, Object> m = new LinkedHashMap<>(); | 441 | Map<String, Object> m = new LinkedHashMap<>(); |
| 252 | m.put("type", "proposal"); | 442 | m.put("type", "proposal"); |
| 253 | m.put("opId", r.path("opId").asText("")); | 443 | m.put("opId", r.path("opId").asText("")); |
| @@ -291,6 +481,7 @@ public class EventProjectionService { | @@ -291,6 +481,7 @@ public class EventProjectionService { | ||
| 291 | case "ai", "assistant", "clarify" -> outcome = str(ev.get("text")); | 481 | case "ai", "assistant", "clarify" -> outcome = str(ev.get("text")); |
| 292 | case "question" -> outcome = "问:" + str(ev.get("question")); | 482 | case "question" -> outcome = "问:" + str(ev.get("question")); |
| 293 | case "form" -> outcome = "弹出「" + str(ev.get("entity")) + "」新建表单"; | 483 | case "form" -> outcome = "弹出「" + str(ev.get("entity")) + "」新建表单"; |
| 484 | + case "queued" -> outcome = "已提交待办:" + str(ev.get("description")); | ||
| 294 | case "proposal" -> outcome = "生成待确认提议:" + str(ev.get("summary")); | 485 | case "proposal" -> outcome = "生成待确认提议:" + str(ev.get("summary")); |
| 295 | case "confirm" -> outcome = ("executed".equals(str(ev.get("status"))) ? "用户确认,执行成功:" : "确认后执行失败:") | 486 | case "confirm" -> outcome = ("executed".equals(str(ev.get("status"))) ? "用户确认,执行成功:" : "确认后执行失败:") |
| 296 | + str(ev.get("description")); | 487 | + str(ev.get("description")); |
| @@ -323,6 +514,27 @@ public class EventProjectionService { | @@ -323,6 +514,27 @@ public class EventProjectionService { | ||
| 323 | return "提交「" + str(ev.get("entity")) + "」新增表单:" + str(ev.get("fields")); | 514 | return "提交「" + str(ev.get("entity")) + "」新增表单:" + str(ev.get("fields")); |
| 324 | } | 515 | } |
| 325 | 516 | ||
| 517 | + private String renderQueued(Map<String, Object> ev) { | ||
| 518 | + return "(系统)用户已保存,操作已提交待办:" + str(ev.get("description")) | ||
| 519 | + + "(AI 侧流程到此为止,是否/何时执行由 ERP 处理;绝不声称已执行成功)"; | ||
| 520 | + } | ||
| 521 | + | ||
| 522 | + /** queued 流程卡行:附 ai_op_queue.sStatus 只读进度(可用时)。 */ | ||
| 523 | + private String renderQueuedLine(Map<String, Object> ev) { | ||
| 524 | + String desc = str(ev.get("description")); | ||
| 525 | + String status = null; | ||
| 526 | + UnaryOperator<String> lookup = opStatusLookup; | ||
| 527 | + Object opIds = ev.get("opIds"); | ||
| 528 | + if (lookup != null && opIds instanceof List<?> ids && !ids.isEmpty()) { | ||
| 529 | + try { | ||
| 530 | + status = lookup.apply(String.valueOf(ids.get(0))); | ||
| 531 | + } catch (Exception ignore) { | ||
| 532 | + } | ||
| 533 | + } | ||
| 534 | + return "已提交待办:" + desc + "(当前状态:" + (status == null || status.isBlank() ? "等待 ERP 处理" : status) | ||
| 535 | + + ";只读进度,勿再重复提交)。"; | ||
| 536 | + } | ||
| 537 | + | ||
| 326 | private String renderOutcome(Map<String, Object> ev) { | 538 | private String renderOutcome(Map<String, Object> ev) { |
| 327 | String desc = str(ev.get("description")); | 539 | String desc = str(ev.get("description")); |
| 328 | if ("cancel".equals(str(ev.get("type")))) { | 540 | if ("cancel".equals(str(ev.get("type")))) { |
| @@ -335,23 +547,6 @@ public class EventProjectionService { | @@ -335,23 +547,6 @@ public class EventProjectionService { | ||
| 335 | return "(系统)用户确认后执行失败:" + desc + (msg.isBlank() ? "" : ",原因:" + msg); | 547 | return "(系统)用户确认后执行失败:" + desc + (msg.isBlank() ? "" : ",原因:" + msg); |
| 336 | } | 548 | } |
| 337 | 549 | ||
| 338 | - private static int approxLen(Map<String, Object> ev) { | ||
| 339 | - String type = str(ev.get("type")); | ||
| 340 | - if ("tool_result".equals(type)) { | ||
| 341 | - return str(ev.get("digest")).length() + 20; // 旧轮只进摘要 | ||
| 342 | - } | ||
| 343 | - if ("tool_call".equals(type)) { | ||
| 344 | - return str(ev.get("payload")).length() / 2 + 20; | ||
| 345 | - } | ||
| 346 | - int n = 0; | ||
| 347 | - for (Object v : ev.values()) { | ||
| 348 | - if (v instanceof String s) { | ||
| 349 | - n += s.length(); | ||
| 350 | - } | ||
| 351 | - } | ||
| 352 | - return Math.max(n, 20); | ||
| 353 | - } | ||
| 354 | - | ||
| 355 | private static void addAi(List<ChatMessage> out, String text) { | 550 | private static void addAi(List<ChatMessage> out, String text) { |
| 356 | if (text != null && !text.isBlank()) { | 551 | if (text != null && !text.isBlank()) { |
| 357 | out.add(AiMessage.from(text)); | 552 | out.add(AiMessage.from(text)); |
src/main/java/com/xly/service/LedgerService.java
| 1 | package com.xly.service; | 1 | package com.xly.service; |
| 2 | 2 | ||
| 3 | import com.fasterxml.jackson.databind.ObjectMapper; | 3 | import com.fasterxml.jackson.databind.ObjectMapper; |
| 4 | +import com.xly.agent.AgentIdentity; | ||
| 4 | import org.slf4j.Logger; | 5 | import org.slf4j.Logger; |
| 5 | import org.slf4j.LoggerFactory; | 6 | import org.slf4j.LoggerFactory; |
| 6 | import org.springframework.data.redis.core.StringRedisTemplate; | 7 | import org.springframework.data.redis.core.StringRedisTemplate; |
| 8 | +import org.springframework.jdbc.core.JdbcTemplate; | ||
| 7 | import org.springframework.stereotype.Service; | 9 | import org.springframework.stereotype.Service; |
| 8 | 10 | ||
| 9 | import java.time.Duration; | 11 | import java.time.Duration; |
| 10 | import java.util.ArrayList; | 12 | import java.util.ArrayList; |
| 13 | +import java.util.Collections; | ||
| 11 | import java.util.LinkedHashMap; | 14 | import java.util.LinkedHashMap; |
| 12 | import java.util.List; | 15 | import java.util.List; |
| 13 | import java.util.Map; | 16 | import java.util.Map; |
| 14 | 17 | ||
| 15 | /** | 18 | /** |
| 16 | - * 会话事件日志(append-only)—— 会话的**唯一事实源**:用户话、模型消息(含工具调用/结果的原样载荷)、 | ||
| 17 | - * 表单/提议/确认等按钮事件,全部按发生顺序落一条 Redis LIST。前端历史与 LLM 上下文都是它的投影 | ||
| 18 | - * ({@link EventProjectionService}),不再有 chat:mem 整包读改写与多处手工同步。 | 19 | + * 会话事件账本(append-only)—— 会话的**唯一事实源**:用户话、模型消息(含工具调用/结果)、 |
| 20 | + * 表单/预览/保存等按钮事件,全部按发生顺序落账。前端历史与 LLM 上下文都是它的投影 | ||
| 21 | + * ({@link EventProjectionService})。 | ||
| 19 | * | 22 | * |
| 20 | - * <p>键:{@code chat:ledger:{convId}},元素为事件 JSON {@code {t,type,...}},30 天 TTL; | ||
| 21 | - * rightPush 原子,任意并发写者(对话流、确认端点)互不覆盖。 | 23 | + * <p><b>存储分层</b>:MySQL {@code ai_chat_event} 为权威持久层(每事件一行、永不删除、可溯源, |
| 24 | + * 服务端赋 iSeq 会话内序号 + iTurn 轮次),Redis LIST {@code chat:ledger:{convId}}(30 天 TTL) | ||
| 25 | + * 为热缓存加速投影读;缓存失效时回源 MySQL 并回填。两边任一失败都不阻断对话(互为兜底)。 | ||
| 22 | * | 26 | * |
| 23 | * <p>事件类型: | 27 | * <p>事件类型: |
| 24 | * {@code user}(text, internal?)/ {@code ai}(text)/ | 28 | * {@code user}(text, internal?)/ {@code ai}(text)/ |
| 25 | - * {@code tool_call}(payload=原样序列化的模型消息, text, tools)/ | 29 | + * {@code tool_call}(text, calls=[{id,name,args}], tools)/ |
| 26 | * {@code tool_result}(tcId, name, text, digest)/ | 30 | * {@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)/ | 31 | + * {@code form_submit}(entity, fields)/ {@code queued}(opIds, description, summaryLines?)/ |
| 29 | * {@code skill_active}(name, text)/ {@code skill_done}(name); | 32 | * {@code skill_active}(name, text)/ {@code skill_done}(name); |
| 30 | - * 旧版遗留类型 {@code assistant/clarify/form/question/tool} 仍可读(兼容渲染,自然淘汰)。 | 33 | + * 旧版遗留类型 {@code assistant/clarify/form/question/tool/proposal/confirm/cancel} 仍可读(兼容渲染,自然淘汰)。 |
| 31 | */ | 34 | */ |
| 32 | @Service | 35 | @Service |
| 33 | public class LedgerService { | 36 | public class LedgerService { |
| @@ -35,20 +38,32 @@ public class LedgerService { | @@ -35,20 +38,32 @@ public class LedgerService { | ||
| 35 | private static final Logger log = LoggerFactory.getLogger(LedgerService.class); | 38 | private static final Logger log = LoggerFactory.getLogger(LedgerService.class); |
| 36 | private static final String PREFIX = "chat:ledger:"; | 39 | private static final String PREFIX = "chat:ledger:"; |
| 37 | private static final Duration TTL = Duration.ofDays(30); | 40 | private static final Duration TTL = Duration.ofDays(30); |
| 41 | + /** MySQL 冷读回源的窗口上限(有界读:超长会话不整包搬运)。 */ | ||
| 42 | + private static final int COLD_READ_MAX = 1000; | ||
| 43 | + /** 开新轮的事件类型(其余事件继承当前轮次)。 */ | ||
| 44 | + private static final java.util.Set<String> TURN_OPENERS = java.util.Set.of("user", "form_submit"); | ||
| 38 | 45 | ||
| 39 | private final StringRedisTemplate redis; | 46 | private final StringRedisTemplate redis; |
| 40 | private final ObjectMapper mapper; | 47 | private final ObjectMapper mapper; |
| 48 | + private final JdbcTemplate jdbc; | ||
| 41 | 49 | ||
| 42 | - public LedgerService(StringRedisTemplate redis, ObjectMapper mapper) { | 50 | + public LedgerService(StringRedisTemplate redis, ObjectMapper mapper, JdbcTemplate jdbc) { |
| 43 | this.redis = redis; | 51 | this.redis = redis; |
| 44 | this.mapper = mapper; | 52 | this.mapper = mapper; |
| 53 | + this.jdbc = jdbc; | ||
| 45 | } | 54 | } |
| 46 | 55 | ||
| 47 | - /** 追加一条事件(绝不抛异常——日志失败不能影响对话主流程)。 */ | 56 | + /** 追加一条事件(无身份上下文的旧签名:sMakePerson 从 convId 前缀推导)。 */ |
| 48 | public void append(String convId, String type, Map<String, Object> data) { | 57 | public void append(String convId, String type, Map<String, Object> data) { |
| 58 | + append(convId, type, data, null); | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + /** 追加一条事件(绝不抛异常——账本失败不能影响对话主流程)。 */ | ||
| 62 | + public void append(String convId, String type, Map<String, Object> data, AgentIdentity who) { | ||
| 49 | if (convId == null || convId.isBlank()) { | 63 | if (convId == null || convId.isBlank()) { |
| 50 | return; | 64 | return; |
| 51 | } | 65 | } |
| 66 | + String json; | ||
| 52 | try { | 67 | try { |
| 53 | Map<String, Object> ev = new LinkedHashMap<>(); | 68 | Map<String, Object> ev = new LinkedHashMap<>(); |
| 54 | ev.put("t", System.currentTimeMillis()); | 69 | ev.put("t", System.currentTimeMillis()); |
| @@ -56,14 +71,70 @@ public class LedgerService { | @@ -56,14 +71,70 @@ public class LedgerService { | ||
| 56 | if (data != null) { | 71 | if (data != null) { |
| 57 | ev.putAll(data); | 72 | ev.putAll(data); |
| 58 | } | 73 | } |
| 74 | + json = mapper.writeValueAsString(ev); | ||
| 75 | + } catch (Exception e) { | ||
| 76 | + log.warn("ledger serialize failed (conv={}, type={}): {}", convId, type, e.getMessage()); | ||
| 77 | + return; | ||
| 78 | + } | ||
| 79 | + persist(convId, type, json, who); | ||
| 80 | + cache(convId, json); | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + /** MySQL 权威落库:服务端赋 iSeq/iTurn,唯一索引 + 重试防并发撞号。失败仅告警(Redis 兜底)。 */ | ||
| 84 | + private void persist(String convId, String type, String json, AgentIdentity who) { | ||
| 85 | + if (jdbc == null) { | ||
| 86 | + return; | ||
| 87 | + } | ||
| 88 | + String maker = who != null && who.userId() != null ? who.userId() : userIdOf(convId); | ||
| 89 | + String brands = who == null ? null : who.brandsId(); | ||
| 90 | + String sub = who == null ? null : who.subsidiaryId(); | ||
| 91 | + for (int attempt = 0; attempt < 3; attempt++) { | ||
| 92 | + try { | ||
| 93 | + int seq = 1; | ||
| 94 | + int turn = TURN_OPENERS.contains(type) ? 1 : 0; | ||
| 95 | + List<Map<String, Object>> last = jdbc.queryForList( | ||
| 96 | + "SELECT iSeq, iTurn FROM ai_chat_event WHERE sConversationId=? ORDER BY iId DESC LIMIT 1", | ||
| 97 | + convId); | ||
| 98 | + if (!last.isEmpty()) { | ||
| 99 | + int lastSeq = ((Number) last.get(0).get("iSeq")).intValue(); | ||
| 100 | + int lastTurn = ((Number) last.get(0).get("iTurn")).intValue(); | ||
| 101 | + seq = lastSeq + 1; | ||
| 102 | + turn = TURN_OPENERS.contains(type) ? lastTurn + 1 : lastTurn; | ||
| 103 | + } | ||
| 104 | + if (turn < 1) { | ||
| 105 | + turn = 1; | ||
| 106 | + } | ||
| 107 | + jdbc.update( | ||
| 108 | + "INSERT INTO ai_chat_event(sConversationId,iSeq,iTurn,sMakePerson,sBrandsId,sSubsidiaryId,sType,sPayload,tCreateDate) " | ||
| 109 | + + "VALUES(?,?,?,?,?,?,?,?,NOW())", | ||
| 110 | + convId, seq, turn, maker, brands, sub, type, json); | ||
| 111 | + return; | ||
| 112 | + } catch (org.springframework.dao.DuplicateKeyException dup) { | ||
| 113 | + // 并发撞号:重读末行再试 | ||
| 114 | + } catch (Exception e) { | ||
| 115 | + log.warn("ledger mysql append failed (conv={}, type={}): {}", convId, type, e.getMessage()); | ||
| 116 | + return; | ||
| 117 | + } | ||
| 118 | + } | ||
| 119 | + log.warn("ledger mysql append gave up after retries (conv={}, type={})", convId, type); | ||
| 120 | + } | ||
| 121 | + | ||
| 122 | + private void cache(String convId, String json) { | ||
| 123 | + try { | ||
| 59 | String key = PREFIX + convId; | 124 | String key = PREFIX + convId; |
| 60 | - redis.opsForList().rightPush(key, mapper.writeValueAsString(ev)); | 125 | + redis.opsForList().rightPush(key, json); |
| 61 | redis.expire(key, TTL); | 126 | redis.expire(key, TTL); |
| 62 | } catch (Exception e) { | 127 | } catch (Exception e) { |
| 63 | - log.warn("ledger append failed (conv={}, type={}): {}", convId, type, e.getMessage()); | 128 | + log.warn("ledger redis append failed (conv={}): {}", convId, e.getMessage()); |
| 64 | } | 129 | } |
| 65 | } | 130 | } |
| 66 | 131 | ||
| 132 | + /** convId 形如 {userId}:{local}(ConversationService.scopedId),前缀即用户 id。 */ | ||
| 133 | + private static String userIdOf(String convId) { | ||
| 134 | + int i = convId.indexOf(':'); | ||
| 135 | + return i > 0 ? convId.substring(0, i) : null; | ||
| 136 | + } | ||
| 137 | + | ||
| 67 | /** 全量事件(按发生顺序),供前端历史投影。 */ | 138 | /** 全量事件(按发生顺序),供前端历史投影。 */ |
| 68 | public List<Map<String, Object>> events(String convId) { | 139 | public List<Map<String, Object>> events(String convId) { |
| 69 | return read(convId, 0); | 140 | return read(convId, 0); |
| @@ -75,28 +146,74 @@ public class LedgerService { | @@ -75,28 +146,74 @@ public class LedgerService { | ||
| 75 | } | 146 | } |
| 76 | 147 | ||
| 77 | private List<Map<String, Object>> read(String convId, int lastN) { | 148 | private List<Map<String, Object>> read(String convId, int lastN) { |
| 78 | - List<Map<String, Object>> out = new ArrayList<>(); | 149 | + List<String> raw = null; |
| 79 | try { | 150 | try { |
| 80 | long start = lastN <= 0 ? 0 : -lastN; | 151 | long start = lastN <= 0 ? 0 : -lastN; |
| 81 | - List<String> raw = redis.opsForList().range(PREFIX + convId, start, -1); | ||
| 82 | - if (raw == null) { | ||
| 83 | - return out; | 152 | + if (Boolean.TRUE.equals(redis.hasKey(PREFIX + convId))) { |
| 153 | + raw = redis.opsForList().range(PREFIX + convId, start, -1); | ||
| 84 | } | 154 | } |
| 85 | - for (String s : raw) { | ||
| 86 | - try { | ||
| 87 | - @SuppressWarnings("unchecked") | ||
| 88 | - Map<String, Object> m = mapper.readValue(s, Map.class); | ||
| 89 | - out.add(m); | ||
| 90 | - } catch (Exception ignore) { | 155 | + } catch (Exception e) { |
| 156 | + log.warn("ledger redis read failed (conv={}): {}", convId, e.getMessage()); | ||
| 157 | + } | ||
| 158 | + if (raw == null) { | ||
| 159 | + raw = coldRead(convId, lastN); | ||
| 160 | + } | ||
| 161 | + List<Map<String, Object>> out = new ArrayList<>(); | ||
| 162 | + for (String s : raw) { | ||
| 163 | + try { | ||
| 164 | + @SuppressWarnings("unchecked") | ||
| 165 | + Map<String, Object> m = mapper.readValue(s, Map.class); | ||
| 166 | + out.add(m); | ||
| 167 | + } catch (Exception ignore) { | ||
| 168 | + } | ||
| 169 | + } | ||
| 170 | + return out; | ||
| 171 | + } | ||
| 172 | + | ||
| 173 | + /** Redis 缓存失效 → 回源 MySQL(有界窗口)并回填缓存。 */ | ||
| 174 | + private List<String> coldRead(String convId, int lastN) { | ||
| 175 | + if (jdbc == null) { | ||
| 176 | + return List.of(); | ||
| 177 | + } | ||
| 178 | + int window = lastN <= 0 ? COLD_READ_MAX : Math.min(lastN, COLD_READ_MAX); | ||
| 179 | + try { | ||
| 180 | + List<Map<String, Object>> rows = jdbc.queryForList( | ||
| 181 | + "SELECT sType, sPayload FROM ai_chat_event WHERE sConversationId=? ORDER BY iId DESC LIMIT " + window, | ||
| 182 | + convId); | ||
| 183 | + if (rows.isEmpty()) { | ||
| 184 | + return List.of(); | ||
| 185 | + } | ||
| 186 | + List<String> out = new ArrayList<>(rows.size()); | ||
| 187 | + for (Map<String, Object> r : rows) { | ||
| 188 | + if ("deleted".equals(String.valueOf(r.get("sType")))) { | ||
| 189 | + break; // 删除标记:更早的事件属于已删除的会话,不再回源 | ||
| 91 | } | 190 | } |
| 191 | + Object p = r.get("sPayload"); | ||
| 192 | + if (p != null) { | ||
| 193 | + out.add(p.toString()); | ||
| 194 | + } | ||
| 195 | + } | ||
| 196 | + if (out.isEmpty()) { | ||
| 197 | + return List.of(); | ||
| 92 | } | 198 | } |
| 199 | + Collections.reverse(out); | ||
| 200 | + try { // 回填热缓存(尽力而为) | ||
| 201 | + String key = PREFIX + convId; | ||
| 202 | + redis.opsForList().rightPushAll(key, out); | ||
| 203 | + redis.expire(key, TTL); | ||
| 204 | + } catch (Exception ignore) { | ||
| 205 | + } | ||
| 206 | + return out; | ||
| 93 | } catch (Exception e) { | 207 | } catch (Exception e) { |
| 94 | - log.warn("ledger read failed (conv={}): {}", convId, e.getMessage()); | 208 | + log.warn("ledger mysql read failed (conv={}): {}", convId, e.getMessage()); |
| 209 | + return List.of(); | ||
| 95 | } | 210 | } |
| 96 | - return out; | ||
| 97 | } | 211 | } |
| 98 | 212 | ||
| 99 | - /** 日志键是否存在(供会话列表懒清理:内容已过期的会话项从列表剔除)。 */ | 213 | + /** |
| 214 | + * 会话在热缓存或持久层是否有内容(供会话列表懒清理)。MySQL 里的事件永不删除, | ||
| 215 | + * 因此这里只看 Redis 热缓存:30 天没动过的会话从侧栏消失,但账本仍可溯源。 | ||
| 216 | + */ | ||
| 100 | public boolean exists(String convId) { | 217 | public boolean exists(String convId) { |
| 101 | try { | 218 | try { |
| 102 | return Boolean.TRUE.equals(redis.hasKey(PREFIX + convId)); | 219 | return Boolean.TRUE.equals(redis.hasKey(PREFIX + convId)); |
| @@ -105,10 +222,15 @@ public class LedgerService { | @@ -105,10 +222,15 @@ public class LedgerService { | ||
| 105 | } | 222 | } |
| 106 | } | 223 | } |
| 107 | 224 | ||
| 225 | + /** | ||
| 226 | + * 删除会话 = 清热缓存 + 在持久层落一条 {@code deleted} 标记(append-only,旧事件永久保留可溯源, | ||
| 227 | + * 但冷读止于标记——同名会话再启用时不会复活已删除的历史)。 | ||
| 228 | + */ | ||
| 108 | public void delete(String convId) { | 229 | public void delete(String convId) { |
| 109 | try { | 230 | try { |
| 110 | redis.delete(PREFIX + convId); | 231 | redis.delete(PREFIX + convId); |
| 111 | } catch (Exception ignore) { | 232 | } catch (Exception ignore) { |
| 112 | } | 233 | } |
| 234 | + persist(convId, "deleted", "{\"type\":\"deleted\"}", null); | ||
| 113 | } | 235 | } |
| 114 | } | 236 | } |
src/main/java/com/xly/service/TokenEstimator.java
0 → 100644
| 1 | +package com.xly.service; | ||
| 2 | + | ||
| 3 | +import dev.langchain4j.data.message.AiMessage; | ||
| 4 | +import dev.langchain4j.data.message.ChatMessage; | ||
| 5 | +import dev.langchain4j.data.message.SystemMessage; | ||
| 6 | +import dev.langchain4j.data.message.ToolExecutionResultMessage; | ||
| 7 | +import dev.langchain4j.data.message.UserMessage; | ||
| 8 | +import dev.langchain4j.agent.tool.ToolExecutionRequest; | ||
| 9 | + | ||
| 10 | +import java.util.List; | ||
| 11 | + | ||
| 12 | +/** | ||
| 13 | + * 本地保守 token 估算器(不依赖任何模型请求)。 | ||
| 14 | + * | ||
| 15 | + * <p>规则:CJK/全角字符 ≈ 1 token/字;其余字符 ≈ 3 字符/token。**故意高估**—— | ||
| 16 | + * 高估只会少带几轮历史,低估会让 Ollama 从前面静默截头(system prompt 先死),两者代价不对称。 | ||
| 17 | + * 校准回路:每次响应免费自带 prompt_eval_count,{@code TracingChatModelListener} 记录 | ||
| 18 | + * 估算 vs 实际,实际逼近 num_ctx 即告警。 | ||
| 19 | + */ | ||
| 20 | +public final class TokenEstimator { | ||
| 21 | + | ||
| 22 | + /** 每条消息的结构开销(role/分隔符等)。 */ | ||
| 23 | + public static final int MSG_OVERHEAD = 8; | ||
| 24 | + | ||
| 25 | + private TokenEstimator() { | ||
| 26 | + } | ||
| 27 | + | ||
| 28 | + public static int estimate(String s) { | ||
| 29 | + if (s == null || s.isEmpty()) { | ||
| 30 | + return 0; | ||
| 31 | + } | ||
| 32 | + int cjk = 0; | ||
| 33 | + int other = 0; | ||
| 34 | + for (int i = 0; i < s.length(); i++) { | ||
| 35 | + if (s.charAt(i) >= 0x2E80) { | ||
| 36 | + cjk++; | ||
| 37 | + } else { | ||
| 38 | + other++; | ||
| 39 | + } | ||
| 40 | + } | ||
| 41 | + return cjk + (other + 2) / 3; | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + public static int estimate(ChatMessage m) { | ||
| 45 | + if (m == null) { | ||
| 46 | + return 0; | ||
| 47 | + } | ||
| 48 | + int n = MSG_OVERHEAD; | ||
| 49 | + if (m instanceof SystemMessage sm) { | ||
| 50 | + n += estimate(sm.text()); | ||
| 51 | + } else if (m instanceof UserMessage um) { | ||
| 52 | + n += estimate(um.hasSingleText() ? um.singleText() : String.valueOf(um.contents())); | ||
| 53 | + } else if (m instanceof AiMessage am) { | ||
| 54 | + n += estimate(am.text()); | ||
| 55 | + if (am.hasToolExecutionRequests()) { | ||
| 56 | + for (ToolExecutionRequest r : am.toolExecutionRequests()) { | ||
| 57 | + n += estimate(r.name()) + estimate(r.arguments()) + 6; | ||
| 58 | + } | ||
| 59 | + } | ||
| 60 | + } else if (m instanceof ToolExecutionResultMessage tr) { | ||
| 61 | + n += estimate(tr.text()) + estimate(tr.toolName()); | ||
| 62 | + } else { | ||
| 63 | + n += estimate(String.valueOf(m)); | ||
| 64 | + } | ||
| 65 | + return n; | ||
| 66 | + } | ||
| 67 | + | ||
| 68 | + public static int estimate(List<ChatMessage> messages) { | ||
| 69 | + int n = 0; | ||
| 70 | + for (ChatMessage m : messages) { | ||
| 71 | + n += estimate(m); | ||
| 72 | + } | ||
| 73 | + return n; | ||
| 74 | + } | ||
| 75 | +} |
src/main/java/com/xly/web/AgentChatController.java
| @@ -95,7 +95,7 @@ public class AgentChatController { | @@ -95,7 +95,7 @@ public class AgentChatController { | ||
| 95 | final String convId = conversations.scopedId(identity, req.conversationId); | 95 | final String convId = conversations.scopedId(identity, req.conversationId); |
| 96 | 96 | ||
| 97 | conversations.touch(identity.userId(), convId, userInput); | 97 | conversations.touch(identity.userId(), convId, userInput); |
| 98 | - ledger.append(convId, "user", Map.of("text", userInput)); | 98 | + ledger.append(convId, "user", Map.of("text", userInput), identity); |
| 99 | 99 | ||
| 100 | exec.submit(() -> { | 100 | exec.submit(() -> { |
| 101 | try { | 101 | try { |
src/main/resources/application.yml
| @@ -68,6 +68,9 @@ llm: | @@ -68,6 +68,9 @@ llm: | ||
| 68 | base-url: ${LLM_BASE_URL:http://112.82.245.194:41434/v1} | 68 | base-url: ${LLM_BASE_URL:http://112.82.245.194:41434/v1} |
| 69 | api-key: ${LLM_API_KEY:ollama} # Ollama 不校验(任意非空);云端填真实 key | 69 | api-key: ${LLM_API_KEY:ollama} # Ollama 不校验(任意非空);云端填真实 key |
| 70 | chat-model: ${LLM_CHAT_MODEL:qwen3.6-27b-iq3:latest} | 70 | chat-model: ${LLM_CHAT_MODEL:qwen3.6-27b-iq3:latest} |
| 71 | + # 镜像 Ollama 侧 OLLAMA_CONTEXT_LENGTH(OpenAI 兼容通道无法逐请求传 num_ctx)。 | ||
| 72 | + # 投影层以它为预算基数;两值漂移由 prompt_eval_count 校准回路兜底(TracingChatModelListener 告警)。 | ||
| 73 | + context-length: ${LLM_CONTEXT_LENGTH:16384} | ||
| 71 | 74 | ||
| 72 | erp: | 75 | erp: |
| 73 | baseurl: ${ERP_BASEURL:http://118.178.19.35:8080/xlyEntry_saas} | 76 | baseurl: ${ERP_BASEURL:http://118.178.19.35:8080/xlyEntry_saas} |
src/test/java/com/xly/service/ConversationScopeTest.java
| @@ -26,7 +26,7 @@ class ConversationScopeTest { | @@ -26,7 +26,7 @@ class ConversationScopeTest { | ||
| 26 | } | 26 | } |
| 27 | 27 | ||
| 28 | private static AgentIdentity user(String id) { | 28 | private static AgentIdentity user(String id) { |
| 29 | - return new AgentIdentity("tok", id, "brand", null); | 29 | + return new AgentIdentity("tok", id, "brand", "sub", null); |
| 30 | } | 30 | } |
| 31 | 31 | ||
| 32 | @Test | 32 | @Test |
src/test/java/com/xly/service/EventLogConcurrencyTest.java
| @@ -30,7 +30,7 @@ class EventLogConcurrencyTest { | @@ -30,7 +30,7 @@ class EventLogConcurrencyTest { | ||
| 30 | void concurrentStreamAndConfirmLoseNothing() throws Exception { | 30 | void concurrentStreamAndConfirmLoseNothing() throws Exception { |
| 31 | InMemoryLedger log = new InMemoryLedger(); | 31 | InMemoryLedger log = new InMemoryLedger(); |
| 32 | EventLogChatMemory mem = new EventLogChatMemory( | 32 | EventLogChatMemory mem = new EventLogChatMemory( |
| 33 | - CONV, log, new EventProjectionService(new ObjectMapper()), 6000, false); | 33 | + CONV, log, new EventProjectionService(new ObjectMapper()), null, false); |
| 34 | 34 | ||
| 35 | int writers = 4; | 35 | int writers = 4; |
| 36 | int perWriter = 100; | 36 | int perWriter = 100; |
src/test/java/com/xly/service/EventProjectionTest.java
| @@ -34,7 +34,7 @@ class EventProjectionTest { | @@ -34,7 +34,7 @@ class EventProjectionTest { | ||
| 34 | } | 34 | } |
| 35 | 35 | ||
| 36 | private EventLogChatMemory memory(InMemoryLedger log, boolean internal) { | 36 | private EventLogChatMemory memory(InMemoryLedger log, boolean internal) { |
| 37 | - return new EventLogChatMemory(CONV, log, proj, 6000, internal); | 37 | + return new EventLogChatMemory(CONV, log, proj, null, internal); |
| 38 | } | 38 | } |
| 39 | 39 | ||
| 40 | private static AiMessage toolCall(String id, String name, String args) { | 40 | private static AiMessage toolCall(String id, String name, String args) { |
| @@ -92,6 +92,8 @@ class EventProjectionTest { | @@ -92,6 +92,8 @@ class EventProjectionTest { | ||
| 92 | 92 | ||
| 93 | @Test | 93 | @Test |
| 94 | void turnsBeyondBudgetBecomeDeterministicDigest() { | 94 | void turnsBeyondBudgetBecomeDeterministicDigest() { |
| 95 | + // 小上下文(6000 → promptBudget≈2976t),每轮约 1500t:只装得下 1-2 轮原文,其余进摘要区 | ||
| 96 | + EventProjectionService small = new EventProjectionService(new ObjectMapper(), 6000); | ||
| 95 | InMemoryLedger log = new InMemoryLedger(); | 97 | InMemoryLedger log = new InMemoryLedger(); |
| 96 | for (int i = 1; i <= 10; i++) { | 98 | for (int i = 1; i <= 10; i++) { |
| 97 | log.append(CONV, "user", Map.of("text", "问题" + i + ":" + "长".repeat(1500))); | 99 | log.append(CONV, "user", Map.of("text", "问题" + i + ":" + "长".repeat(1500))); |
| @@ -99,7 +101,7 @@ class EventProjectionTest { | @@ -99,7 +101,7 @@ class EventProjectionTest { | ||
| 99 | } | 101 | } |
| 100 | log.append(CONV, "user", Map.of("text", "当前问题")); | 102 | log.append(CONV, "user", Map.of("text", "当前问题")); |
| 101 | 103 | ||
| 102 | - List<ChatMessage> ms = proj.project("SYS", log.events(CONV), 6000); | 104 | + List<ChatMessage> ms = small.project("SYS", log.events(CONV)); |
| 103 | UserMessage digest = (UserMessage) ms.stream() | 105 | UserMessage digest = (UserMessage) ms.stream() |
| 104 | .filter(m -> m instanceof UserMessage u && u.singleText().startsWith("【更早对话摘要")) | 106 | .filter(m -> m instanceof UserMessage u && u.singleText().startsWith("【更早对话摘要")) |
| 105 | .findFirst().orElseThrow(); | 107 | .findFirst().orElseThrow(); |
| @@ -107,9 +109,32 @@ class EventProjectionTest { | @@ -107,9 +109,32 @@ class EventProjectionTest { | ||
| 107 | assertTrue(digest.singleText().contains("⇒ 回答1"), "摘要含结果"); | 109 | assertTrue(digest.singleText().contains("⇒ 回答1"), "摘要含结果"); |
| 108 | long verbatimUsers = ms.stream().filter(m -> m instanceof UserMessage u | 110 | long verbatimUsers = ms.stream().filter(m -> m instanceof UserMessage u |
| 109 | && !u.singleText().startsWith("【更早对话摘要")).count(); | 111 | && !u.singleText().startsWith("【更早对话摘要")).count(); |
| 110 | - assertTrue(verbatimUsers <= 5, "近期原文区按 6000 字预算收拢(每轮约 1500 字)"); | 112 | + assertTrue(verbatimUsers <= 3, "近期原文区按 token 预算收拢(每轮约 1500t)"); |
| 111 | assertTrue(ms.get(ms.size() - 1) instanceof UserMessage u && u.singleText().equals("当前问题"), | 113 | assertTrue(ms.get(ms.size() - 1) instanceof UserMessage u && u.singleText().equals("当前问题"), |
| 112 | "当前轮永在末尾且完整"); | 114 | "当前轮永在末尾且完整"); |
| 115 | + assertTrue(TokenEstimator.estimate(ms) <= small.promptBudget(), "发送前自检:总量不超预算"); | ||
| 116 | + } | ||
| 117 | + | ||
| 118 | + @Test | ||
| 119 | + void currentTurnDemotesOldestToolResultsWhenOversized() { | ||
| 120 | + // 当前轮 4 个各约 1200t 的工具结果 + 小上下文:最早的降 120 字摘要,最近 2 个保全文,配对不破坏 | ||
| 121 | + EventProjectionService small = new EventProjectionService(new ObjectMapper(), 8000); | ||
| 122 | + InMemoryLedger log = new InMemoryLedger(); | ||
| 123 | + EventLogChatMemory mem = new EventLogChatMemory(CONV, log, small, null, false); | ||
| 124 | + log.append(CONV, "user", Map.of("text", "查四张表")); | ||
| 125 | + for (int i = 1; i <= 4; i++) { | ||
| 126 | + mem.add(toolCall("t" + i, "readFormData", "{\"formId\":\"F" + i + "\"}")); | ||
| 127 | + mem.add(ToolExecutionResultMessage.from("t" + i, "readFormData", "行".repeat(1200))); | ||
| 128 | + } | ||
| 129 | + mem.add(SystemMessage.from("SYS")); | ||
| 130 | + List<ChatMessage> ms = mem.messages(); | ||
| 131 | + List<ToolExecutionResultMessage> trs = ms.stream() | ||
| 132 | + .filter(m -> m instanceof ToolExecutionResultMessage) | ||
| 133 | + .map(m -> (ToolExecutionResultMessage) m).toList(); | ||
| 134 | + assertEquals(4, trs.size(), "工具调用/结果配对一个不丢"); | ||
| 135 | + assertTrue(trs.get(0).text().length() <= EventProjectionService.TOOL_DIGEST_LEN + 1, "最早结果降摘要"); | ||
| 136 | + assertEquals(1200, trs.get(3).text().length(), "最近结果保全文"); | ||
| 137 | + assertTrue(TokenEstimator.estimate(ms) <= small.promptBudget(), "轮内让位后不超预算"); | ||
| 113 | } | 138 | } |
| 114 | 139 | ||
| 115 | @Test | 140 | @Test |
| @@ -138,7 +163,7 @@ class EventProjectionTest { | @@ -138,7 +163,7 @@ class EventProjectionTest { | ||
| 138 | log.append(CONV, "ai", Map.of("text", "大约有 1286 个客户。")); | 163 | log.append(CONV, "ai", Map.of("text", "大约有 1286 个客户。")); |
| 139 | memory(log, true).add(UserMessage.from("你上一条回答没有调用任何工具,请先查询真实数据。")); | 164 | memory(log, true).add(UserMessage.from("你上一条回答没有调用任何工具,请先查询真实数据。")); |
| 140 | 165 | ||
| 141 | - List<ChatMessage> ms = proj.project("SYS", log.events(CONV), 6000); | 166 | + List<ChatMessage> ms = proj.project("SYS", log.events(CONV)); |
| 142 | assertTrue(ms.stream().anyMatch(m -> m instanceof UserMessage u | 167 | assertTrue(ms.stream().anyMatch(m -> m instanceof UserMessage u |
| 143 | && u.singleText().contains("没有调用任何工具")), "注入话术 LLM 可见"); | 168 | && u.singleText().contains("没有调用任何工具")), "注入话术 LLM 可见"); |
| 144 | 169 | ||
| @@ -160,7 +185,7 @@ class EventProjectionTest { | @@ -160,7 +185,7 @@ class EventProjectionTest { | ||
| 160 | assertTrue(hist.get(1).get("content").startsWith("【待确认】")); | 185 | assertTrue(hist.get(1).get("content").startsWith("【待确认】")); |
| 161 | assertTrue(hist.get(2).get("content").startsWith("【已执行】")); | 186 | assertTrue(hist.get(2).get("content").startsWith("【已执行】")); |
| 162 | 187 | ||
| 163 | - List<ChatMessage> ms = proj.project("SYS", log.events(CONV), 6000); | 188 | + List<ChatMessage> ms = proj.project("SYS", log.events(CONV)); |
| 164 | assertTrue(ms.stream().anyMatch(m -> m instanceof UserMessage u && u.singleText().contains("提交「报价」"))); | 189 | 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("已生成待确认提议"))); | 190 | 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("操作执行成功"))); | 191 | assertTrue(ms.stream().anyMatch(m -> m instanceof AiMessage a && a.text().contains("操作执行成功"))); |
| @@ -197,7 +222,7 @@ class EventProjectionTest { | @@ -197,7 +222,7 @@ class EventProjectionTest { | ||
| 197 | "{\"type\":\"form_collect\",\"entity\":\"报价\",\"message\":\"请填写\"}")); | 222 | "{\"type\":\"form_collect\",\"entity\":\"报价\",\"message\":\"请填写\"}")); |
| 198 | String card = proj.activeCard(log.events(CONV)); | 223 | String card = proj.activeCard(log.events(CONV)); |
| 199 | assertTrue(card != null && card.contains("「报价」"), "弹表单后流程卡钉住"); | 224 | assertTrue(card != null && card.contains("「报价」"), "弹表单后流程卡钉住"); |
| 200 | - assertTrue(proj.project("SYS", log.events(CONV), 6000).get(0).toString().contains("进行中的流程")); | 225 | + assertTrue(proj.project("SYS", log.events(CONV)).get(0).toString().contains("进行中的流程")); |
| 201 | 226 | ||
| 202 | // 中途插入无关查询轮 → 卡不摘 | 227 | // 中途插入无关查询轮 → 卡不摘 |
| 203 | log.append(CONV, "user", Map.of("text", "先查下必胜客电话")); | 228 | log.append(CONV, "user", Map.of("text", "先查下必胜客电话")); |
| @@ -241,7 +266,7 @@ class EventProjectionTest { | @@ -241,7 +266,7 @@ class EventProjectionTest { | ||
| 241 | assertEquals(5, hist.size(), "tool 摘要事件不进历史,其余照旧"); | 266 | assertEquals(5, hist.size(), "tool 摘要事件不进历史,其余照旧"); |
| 242 | assertTrue(hist.get(3).get("content").contains("【表单】新建报价")); | 267 | assertTrue(hist.get(3).get("content").contains("【表单】新建报价")); |
| 243 | 268 | ||
| 244 | - List<ChatMessage> ms = proj.project("SYS", log.events(CONV), 6000); | 269 | + List<ChatMessage> ms = proj.project("SYS", log.events(CONV)); |
| 245 | assertTrue(ms.stream().anyMatch(m -> m instanceof AiMessage a && a.text().contains("请问改成什么号码"))); | 270 | assertTrue(ms.stream().anyMatch(m -> m instanceof AiMessage a && a.text().contains("请问改成什么号码"))); |
| 246 | assertFalse(ms.stream().anyMatch(m -> m.toString().contains("findForms")), "旧 tool 摘要不进 LLM 正文"); | 271 | assertFalse(ms.stream().anyMatch(m -> m.toString().contains("findForms")), "旧 tool 摘要不进 LLM 正文"); |
| 247 | } | 272 | } |
src/test/java/com/xly/service/InMemoryLedger.java
| 1 | package com.xly.service; | 1 | package com.xly.service; |
| 2 | 2 | ||
| 3 | import com.fasterxml.jackson.databind.ObjectMapper; | 3 | import com.fasterxml.jackson.databind.ObjectMapper; |
| 4 | +import com.xly.agent.AgentIdentity; | ||
| 4 | 5 | ||
| 5 | import java.util.ArrayList; | 6 | import java.util.ArrayList; |
| 6 | import java.util.Collections; | 7 | import java.util.Collections; |
| @@ -9,7 +10,7 @@ import java.util.List; | @@ -9,7 +10,7 @@ import java.util.List; | ||
| 9 | import java.util.Map; | 10 | import java.util.Map; |
| 10 | 11 | ||
| 11 | /** | 12 | /** |
| 12 | - * 测试替身:内存版事件日志。append 与 Redis rightPush 同为「整条原子追加」, | 13 | + * 测试替身:内存版事件账本。append 与生产(MySQL insert + Redis rightPush)同为「整条原子追加」, |
| 13 | * 用于验证投影逻辑与「任意并发写者只追加、绝无整包读改写」的设计性质。 | 14 | * 用于验证投影逻辑与「任意并发写者只追加、绝无整包读改写」的设计性质。 |
| 14 | */ | 15 | */ |
| 15 | class InMemoryLedger extends LedgerService { | 16 | class InMemoryLedger extends LedgerService { |
| @@ -18,11 +19,11 @@ class InMemoryLedger extends LedgerService { | @@ -18,11 +19,11 @@ class InMemoryLedger extends LedgerService { | ||
| 18 | private final ObjectMapper mapper = new ObjectMapper(); | 19 | private final ObjectMapper mapper = new ObjectMapper(); |
| 19 | 20 | ||
| 20 | InMemoryLedger() { | 21 | InMemoryLedger() { |
| 21 | - super(null, new ObjectMapper()); | 22 | + super(null, new ObjectMapper(), null); |
| 22 | } | 23 | } |
| 23 | 24 | ||
| 24 | @Override | 25 | @Override |
| 25 | - public void append(String convId, String type, Map<String, Object> data) { | 26 | + public void append(String convId, String type, Map<String, Object> data, AgentIdentity who) { |
| 26 | Map<String, Object> ev = new LinkedHashMap<>(); | 27 | Map<String, Object> ev = new LinkedHashMap<>(); |
| 27 | ev.put("t", 0L); | 28 | ev.put("t", 0L); |
| 28 | ev.put("type", type); | 29 | ev.put("type", type); |