Commit 0b89179e5cfa69809b50b80bd68e0073b53db127
1 parent
7d3be390
feat(context): conversation ledger, code-written state slots, token-budget proje…
…ction, anti-fabrication guards - LedgerService: append-only chat{conv} events incl. deterministic-path form/clarify/proposal/confirm-outcome; history replays from ledger - StateService: chat:state:{conv} slots (上轮意图/最近实体/在办单据) written by code, fed to intent gate + write-slot extraction and appended to agent user text - ProjectedChatMemory replaces MessageWindowChatMemory: full store + char-budget projection, old tool results collapsed to one line, current turn kept intact - RedisChatMemoryStore.appendTurn patches memory holes on deterministic paths; op confirm/cancel outcomes recorded to ledger/state/memory - anti-fabrication (tool_choice=required NOT enforced by Ollama 0.32.3, retested): query turn zero-tools+digits → one forced retry then flag; write claim without proposeWrite → corrective notice
Showing
9 changed files
with
720 additions
and
38 deletions
src/main/java/com/xly/agent/ProjectedChatMemory.java
0 → 100644
| 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
| @@ -12,7 +12,7 @@ import com.xly.tool.FormCollectTool; | @@ -12,7 +12,7 @@ import com.xly.tool.FormCollectTool; | ||
| 12 | import com.xly.tool.InteractionTool; | 12 | import com.xly.tool.InteractionTool; |
| 13 | import com.xly.tool.KgQueryTool; | 13 | import com.xly.tool.KgQueryTool; |
| 14 | import com.xly.tool.ProposeWriteTool; | 14 | import com.xly.tool.ProposeWriteTool; |
| 15 | -import dev.langchain4j.memory.chat.MessageWindowChatMemory; | 15 | +import com.xly.agent.ProjectedChatMemory; |
| 16 | import dev.langchain4j.model.chat.StreamingChatModel; | 16 | import dev.langchain4j.model.chat.StreamingChatModel; |
| 17 | import dev.langchain4j.service.AiServices; | 17 | import dev.langchain4j.service.AiServices; |
| 18 | import org.springframework.beans.factory.annotation.Qualifier; | 18 | import org.springframework.beans.factory.annotation.Qualifier; |
| @@ -77,11 +77,8 @@ public class AgentFactory { | @@ -77,11 +77,8 @@ public class AgentFactory { | ||
| 77 | .streamingChatModel(streamingModel) | 77 | .streamingChatModel(streamingModel) |
| 78 | .tools(tools) | 78 | .tools(tools) |
| 79 | .maxSequentialToolsInvocations(8) // 循环护栏:防止 askUser/工具无限自我循环 | 79 | .maxSequentialToolsInvocations(8) // 循环护栏:防止 askUser/工具无限自我循环 |
| 80 | - .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() | ||
| 81 | - .id(memoryId) | ||
| 82 | - .maxMessages(30) | ||
| 83 | - .chatMemoryStore(memoryStore) | ||
| 84 | - .build()) | 80 | + // token 预算投影记忆:存储保完整、读取按预算收拢,旧工具结果压一行(见 ProjectedChatMemory) |
| 81 | + .chatMemoryProvider(memoryId -> new ProjectedChatMemory(memoryId, memoryStore, 6000, 80)) | ||
| 85 | .systemMessageProvider(memoryId -> systemPromptService.prompt()) | 82 | .systemMessageProvider(memoryId -> systemPromptService.prompt()) |
| 86 | .build(); | 83 | .build(); |
| 87 | } | 84 | } |
src/main/java/com/xly/config/RedisChatMemoryStore.java
| 1 | package com.xly.config; | 1 | package com.xly.config; |
| 2 | 2 | ||
| 3 | +import dev.langchain4j.data.message.AiMessage; | ||
| 3 | import dev.langchain4j.data.message.ChatMessage; | 4 | import dev.langchain4j.data.message.ChatMessage; |
| 4 | import dev.langchain4j.data.message.ChatMessageDeserializer; | 5 | import dev.langchain4j.data.message.ChatMessageDeserializer; |
| 5 | import dev.langchain4j.data.message.ChatMessageSerializer; | 6 | import dev.langchain4j.data.message.ChatMessageSerializer; |
| 7 | +import dev.langchain4j.data.message.UserMessage; | ||
| 6 | import dev.langchain4j.store.memory.chat.ChatMemoryStore; | 8 | import dev.langchain4j.store.memory.chat.ChatMemoryStore; |
| 7 | import org.springframework.data.redis.core.StringRedisTemplate; | 9 | import org.springframework.data.redis.core.StringRedisTemplate; |
| 8 | import org.springframework.stereotype.Component; | 10 | import org.springframework.stereotype.Component; |
| @@ -47,4 +49,16 @@ public class RedisChatMemoryStore implements ChatMemoryStore { | @@ -47,4 +49,16 @@ public class RedisChatMemoryStore implements ChatMemoryStore { | ||
| 47 | public void deleteMessages(Object memoryId) { | 49 | public void deleteMessages(Object memoryId) { |
| 48 | redis.delete(PREFIX + memoryId); | 50 | redis.delete(PREFIX + memoryId); |
| 49 | } | 51 | } |
| 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 | + } | ||
| 50 | } | 64 | } |
src/main/java/com/xly/service/ConversationService.java
| @@ -27,11 +27,16 @@ public class ConversationService { | @@ -27,11 +27,16 @@ public class ConversationService { | ||
| 27 | private final StringRedisTemplate redis; | 27 | private final StringRedisTemplate redis; |
| 28 | private final RedisChatMemoryStore memoryStore; | 28 | private final RedisChatMemoryStore memoryStore; |
| 29 | private final ObjectMapper mapper; | 29 | private final ObjectMapper mapper; |
| 30 | + private final LedgerService ledger; | ||
| 31 | + private final StateService state; | ||
| 30 | 32 | ||
| 31 | - public ConversationService(StringRedisTemplate redis, RedisChatMemoryStore memoryStore, ObjectMapper mapper) { | 33 | + public ConversationService(StringRedisTemplate redis, RedisChatMemoryStore memoryStore, |
| 34 | + ObjectMapper mapper, LedgerService ledger, StateService state) { | ||
| 32 | this.redis = redis; | 35 | this.redis = redis; |
| 33 | this.memoryStore = memoryStore; | 36 | this.memoryStore = memoryStore; |
| 34 | this.mapper = mapper; | 37 | this.mapper = mapper; |
| 38 | + this.ledger = ledger; | ||
| 39 | + this.state = state; | ||
| 35 | } | 40 | } |
| 36 | 41 | ||
| 37 | /** 新建一个空会话,返回 convId。 */ | 42 | /** 新建一个空会话,返回 convId。 */ |
| @@ -87,10 +92,34 @@ public class ConversationService { | @@ -87,10 +92,34 @@ public class ConversationService { | ||
| 87 | public void delete(String userId, String convId) { | 92 | public void delete(String userId, String convId) { |
| 88 | redis.opsForHash().delete(CONVS_KEY + userId, convId); | 93 | redis.opsForHash().delete(CONVS_KEY + userId, convId); |
| 89 | memoryStore.deleteMessages(convId); | 94 | memoryStore.deleteMessages(convId); |
| 95 | + ledger.delete(convId); | ||
| 96 | + state.delete(convId); | ||
| 90 | } | 97 | } |
| 91 | 98 | ||
| 92 | - /** 会话历史,映射为 {role:user|ai, content}。跳过系统消息、工具调用中间消息、工具结果。 */ | 99 | + /** |
| 100 | + * 会话历史:优先从**会话账本**重放(含确定性路径的表单/澄清/提议/确认结果——消息记忆里没有这些); | ||
| 101 | + * 无账本的旧会话退回消息记忆。映射为 {role:user|ai, content}。 | ||
| 102 | + */ | ||
| 93 | public List<Map<String, String>> history(String convId) { | 103 | public List<Map<String, String>> history(String convId) { |
| 104 | + List<Map<String, Object>> events = ledger.events(convId); | ||
| 105 | + if (!events.isEmpty()) { | ||
| 106 | + List<Map<String, String>> out = new ArrayList<>(); | ||
| 107 | + for (Map<String, Object> e : events) { | ||
| 108 | + String type = String.valueOf(e.get("type")); | ||
| 109 | + switch (type) { | ||
| 110 | + case "user" -> out.add(Map.of("role", "user", "content", s(e.get("text")))); | ||
| 111 | + case "assistant", "clarify" -> addAi(out, s(e.get("text"))); | ||
| 112 | + case "question" -> addAi(out, s(e.get("question"))); | ||
| 113 | + case "form" -> addAi(out, "【表单】新建" + s(e.get("entity")) + ":" + s(e.get("message"))); | ||
| 114 | + case "proposal" -> addAi(out, "【待确认】" + s(e.get("summary"))); | ||
| 115 | + case "confirm" -> addAi(out, ("executed".equals(s(e.get("status"))) ? "【已执行】" : "【执行失败】") | ||
| 116 | + + s(e.get("description")) + blankOr(s(e.get("msg")))); | ||
| 117 | + case "cancel" -> addAi(out, "【已取消】" + s(e.get("description"))); | ||
| 118 | + default -> { } // tool 等内部事件不进历史 | ||
| 119 | + } | ||
| 120 | + } | ||
| 121 | + return out; | ||
| 122 | + } | ||
| 94 | List<Map<String, String>> out = new ArrayList<>(); | 123 | List<Map<String, String>> out = new ArrayList<>(); |
| 95 | for (ChatMessage m : memoryStore.getMessages(convId)) { | 124 | for (ChatMessage m : memoryStore.getMessages(convId)) { |
| 96 | if (m instanceof UserMessage um && um.hasSingleText()) { | 125 | if (m instanceof UserMessage um && um.hasSingleText()) { |
| @@ -103,6 +132,20 @@ public class ConversationService { | @@ -103,6 +132,20 @@ public class ConversationService { | ||
| 103 | return out; | 132 | return out; |
| 104 | } | 133 | } |
| 105 | 134 | ||
| 135 | + private static void addAi(List<Map<String, String>> out, String text) { | ||
| 136 | + if (text != null && !text.isBlank()) { | ||
| 137 | + out.add(Map.of("role", "ai", "content", text)); | ||
| 138 | + } | ||
| 139 | + } | ||
| 140 | + | ||
| 141 | + private static String s(Object o) { | ||
| 142 | + return o == null ? "" : o.toString(); | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + private static String blankOr(String msg) { | ||
| 146 | + return msg == null || msg.isBlank() ? "" : ("(" + msg + ")"); | ||
| 147 | + } | ||
| 148 | + | ||
| 106 | private String deriveTitle(String s) { | 149 | private String deriveTitle(String s) { |
| 107 | if (s == null) { | 150 | if (s == null) { |
| 108 | return "新会话"; | 151 | return "新会话"; |
src/main/java/com/xly/service/IntentService.java
| @@ -47,11 +47,16 @@ public class IntentService { | @@ -47,11 +47,16 @@ public class IntentService { | ||
| 47 | 47 | ||
| 48 | /** 意图门主入口。utterance 为空或模型失败时返回 其他。 */ | 48 | /** 意图门主入口。utterance 为空或模型失败时返回 其他。 */ |
| 49 | public Intent classify(String utterance) { | 49 | public Intent classify(String utterance) { |
| 50 | + return classify(utterance, null); | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + /** 带会话状态摘要的分类:状态槽让「那张单子」这类跨轮指代可解。 */ | ||
| 54 | + public Intent classify(String utterance, String stateDigest) { | ||
| 50 | Intent out = new Intent(); | 55 | Intent out = new Intent(); |
| 51 | if (utterance == null || utterance.isBlank()) { | 56 | if (utterance == null || utterance.isBlank()) { |
| 52 | return out; | 57 | return out; |
| 53 | } | 58 | } |
| 54 | - JsonNode n = llm.completeJson(SYSTEM, utterance.trim(), schema()); | 59 | + JsonNode n = llm.completeJson(SYSTEM, withState(utterance, stateDigest), schema()); |
| 55 | if (n == null) { | 60 | if (n == null) { |
| 56 | log.warn("intent classify fell back to 其他 (model unavailable)"); | 61 | log.warn("intent classify fell back to 其他 (model unavailable)"); |
| 57 | return out; | 62 | return out; |
| @@ -88,11 +93,16 @@ public class IntentService { | @@ -88,11 +93,16 @@ public class IntentService { | ||
| 88 | 93 | ||
| 89 | /** 抽取写操作槽位(修改/删除/审核用)。失败返回空槽位。 */ | 94 | /** 抽取写操作槽位(修改/删除/审核用)。失败返回空槽位。 */ |
| 90 | public Intent.WriteSlots extractWrite(String utterance) { | 95 | public Intent.WriteSlots extractWrite(String utterance) { |
| 96 | + return extractWrite(utterance, null); | ||
| 97 | + } | ||
| 98 | + | ||
| 99 | + /** 带会话状态摘要的写槽位抽取(「把那张报价单作废」的 record 可从状态里补齐)。 */ | ||
| 100 | + public Intent.WriteSlots extractWrite(String utterance, String stateDigest) { | ||
| 91 | Intent.WriteSlots w = new Intent.WriteSlots(); | 101 | Intent.WriteSlots w = new Intent.WriteSlots(); |
| 92 | if (utterance == null || utterance.isBlank()) { | 102 | if (utterance == null || utterance.isBlank()) { |
| 93 | return w; | 103 | return w; |
| 94 | } | 104 | } |
| 95 | - JsonNode n = llm.completeJson(WRITE_SYSTEM, utterance.trim(), writeSchema()); | 105 | + JsonNode n = llm.completeJson(WRITE_SYSTEM, withState(utterance, stateDigest), writeSchema()); |
| 96 | if (n == null) { | 106 | if (n == null) { |
| 97 | return w; | 107 | return w; |
| 98 | } | 108 | } |
| @@ -116,6 +126,15 @@ public class IntentService { | @@ -116,6 +126,15 @@ public class IntentService { | ||
| 116 | return schema; | 126 | return schema; |
| 117 | } | 127 | } |
| 118 | 128 | ||
| 129 | + /** 状态摘要作为输入前缀(有状态才加,单句冷启动时输入形态不变)。 */ | ||
| 130 | + private static String withState(String utterance, String stateDigest) { | ||
| 131 | + String u = utterance.trim(); | ||
| 132 | + if (stateDigest == null || stateDigest.isBlank()) { | ||
| 133 | + return u; | ||
| 134 | + } | ||
| 135 | + return "【会话状态】" + stateDigest + "\n【这句话】" + u; | ||
| 136 | + } | ||
| 137 | + | ||
| 119 | private static String normalizeIntent(String s) { | 138 | private static String normalizeIntent(String s) { |
| 120 | if (s == null) return Intent.OTHER; | 139 | if (s == null) return Intent.OTHER; |
| 121 | s = s.trim(); | 140 | s = s.trim(); |
src/main/java/com/xly/service/LedgerService.java
0 → 100644
| 1 | +package com.xly.service; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 4 | +import org.slf4j.Logger; | ||
| 5 | +import org.slf4j.LoggerFactory; | ||
| 6 | +import org.springframework.data.redis.core.StringRedisTemplate; | ||
| 7 | +import org.springframework.stereotype.Service; | ||
| 8 | + | ||
| 9 | +import java.time.Duration; | ||
| 10 | +import java.util.ArrayList; | ||
| 11 | +import java.util.LinkedHashMap; | ||
| 12 | +import java.util.List; | ||
| 13 | +import java.util.Map; | ||
| 14 | + | ||
| 15 | +/** | ||
| 16 | + * 会话账本(append-only 事件流)—— 会话里发生过的**一切**按序落账,包括不经过 LLM 的确定性路径 | ||
| 17 | + * (表单弹出/澄清/写提议/确认结果),修补「确定性路径不进对话记忆」的记忆空洞;前端历史从账本重放。 | ||
| 18 | + * | ||
| 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)。 | ||
| 23 | + */ | ||
| 24 | +@Service | ||
| 25 | +public class LedgerService { | ||
| 26 | + | ||
| 27 | + private static final Logger log = LoggerFactory.getLogger(LedgerService.class); | ||
| 28 | + private static final String PREFIX = "chat:ledger:"; | ||
| 29 | + private static final Duration TTL = Duration.ofDays(30); | ||
| 30 | + | ||
| 31 | + private final StringRedisTemplate redis; | ||
| 32 | + private final ObjectMapper mapper; | ||
| 33 | + | ||
| 34 | + public LedgerService(StringRedisTemplate redis, ObjectMapper mapper) { | ||
| 35 | + this.redis = redis; | ||
| 36 | + this.mapper = mapper; | ||
| 37 | + } | ||
| 38 | + | ||
| 39 | + /** 追加一条事件(绝不抛异常——账本失败不能影响对话主流程)。 */ | ||
| 40 | + public void append(String convId, String type, Map<String, Object> data) { | ||
| 41 | + if (convId == null || convId.isBlank()) { | ||
| 42 | + return; | ||
| 43 | + } | ||
| 44 | + try { | ||
| 45 | + Map<String, Object> ev = new LinkedHashMap<>(); | ||
| 46 | + ev.put("t", System.currentTimeMillis()); | ||
| 47 | + ev.put("type", type); | ||
| 48 | + if (data != null) { | ||
| 49 | + ev.putAll(data); | ||
| 50 | + } | ||
| 51 | + String key = PREFIX + convId; | ||
| 52 | + redis.opsForList().rightPush(key, mapper.writeValueAsString(ev)); | ||
| 53 | + redis.expire(key, TTL); | ||
| 54 | + } catch (Exception e) { | ||
| 55 | + log.warn("ledger append failed (conv={}, type={}): {}", convId, type, e.getMessage()); | ||
| 56 | + } | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + /** 全量事件(按发生顺序),供前端历史重放。 */ | ||
| 60 | + public List<Map<String, Object>> events(String convId) { | ||
| 61 | + List<Map<String, Object>> out = new ArrayList<>(); | ||
| 62 | + try { | ||
| 63 | + List<String> raw = redis.opsForList().range(PREFIX + convId, 0, -1); | ||
| 64 | + if (raw == null) { | ||
| 65 | + return out; | ||
| 66 | + } | ||
| 67 | + for (String s : raw) { | ||
| 68 | + try { | ||
| 69 | + @SuppressWarnings("unchecked") | ||
| 70 | + Map<String, Object> m = mapper.readValue(s, Map.class); | ||
| 71 | + out.add(m); | ||
| 72 | + } catch (Exception ignore) { | ||
| 73 | + } | ||
| 74 | + } | ||
| 75 | + } catch (Exception e) { | ||
| 76 | + log.warn("ledger read failed (conv={}): {}", convId, e.getMessage()); | ||
| 77 | + } | ||
| 78 | + return out; | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + public void delete(String convId) { | ||
| 82 | + try { | ||
| 83 | + redis.delete(PREFIX + convId); | ||
| 84 | + } catch (Exception ignore) { | ||
| 85 | + } | ||
| 86 | + } | ||
| 87 | +} |
src/main/java/com/xly/service/StateService.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 com.fasterxml.jackson.databind.node.ArrayNode; | ||
| 6 | +import com.fasterxml.jackson.databind.node.ObjectNode; | ||
| 7 | +import com.xly.agent.Intent; | ||
| 8 | +import org.slf4j.Logger; | ||
| 9 | +import org.slf4j.LoggerFactory; | ||
| 10 | +import org.springframework.data.redis.core.StringRedisTemplate; | ||
| 11 | +import org.springframework.stereotype.Service; | ||
| 12 | + | ||
| 13 | +import java.time.Duration; | ||
| 14 | +import java.util.List; | ||
| 15 | + | ||
| 16 | +/** | ||
| 17 | + * 会话状态槽 —— 由**代码**(而非模型总结)维护的少量结构化状态:上轮意图 / 最近实体 / 在办单据。 | ||
| 18 | + * 注入两处:意图门的输入前缀(让「那张单子」这类指代可解),agent 用户消息尾部(稳住多轮上下文)。 | ||
| 19 | + * | ||
| 20 | + * <p>键:Redis HASH {@code chat:state:{convId}}(fields: intent/danju/entities/doc),30 天 TTL。 | ||
| 21 | + */ | ||
| 22 | +@Service | ||
| 23 | +public class StateService { | ||
| 24 | + | ||
| 25 | + private static final Logger log = LoggerFactory.getLogger(StateService.class); | ||
| 26 | + private static final String PREFIX = "chat:state:"; | ||
| 27 | + private static final Duration TTL = Duration.ofDays(30); | ||
| 28 | + private static final int MAX_ENTITIES = 8; | ||
| 29 | + | ||
| 30 | + private final StringRedisTemplate redis; | ||
| 31 | + private final ObjectMapper mapper; | ||
| 32 | + | ||
| 33 | + public StateService(StringRedisTemplate redis, ObjectMapper mapper) { | ||
| 34 | + this.redis = redis; | ||
| 35 | + this.mapper = mapper; | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + /** 记录本轮意图门结果(上轮意图 + 单据类型)。 */ | ||
| 39 | + public void recordIntent(String convId, String intent, String danju) { | ||
| 40 | + try { | ||
| 41 | + String key = PREFIX + convId; | ||
| 42 | + redis.opsForHash().put(key, "intent", intent == null ? "" : intent); | ||
| 43 | + redis.opsForHash().put(key, "danju", danju == null ? "" : danju); | ||
| 44 | + redis.expire(key, TTL); | ||
| 45 | + } catch (Exception e) { | ||
| 46 | + log.warn("state recordIntent failed (conv={}): {}", convId, e.getMessage()); | ||
| 47 | + } | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + /** 合并本轮识别到的实体(最新在前、按 值+角色 去重、封顶 {@value #MAX_ENTITIES} 个)。 */ | ||
| 51 | + public void mergeEntities(String convId, List<Intent.Entity> entities) { | ||
| 52 | + if (entities == null || entities.isEmpty()) { | ||
| 53 | + return; | ||
| 54 | + } | ||
| 55 | + try { | ||
| 56 | + String key = PREFIX + convId; | ||
| 57 | + ArrayNode merged = mapper.createArrayNode(); | ||
| 58 | + for (Intent.Entity e : entities) { | ||
| 59 | + if (e == null || e.value == null || e.value.isBlank()) continue; | ||
| 60 | + ObjectNode n = merged.addObject(); | ||
| 61 | + n.put("value", e.value.trim()); | ||
| 62 | + n.put("role", e.role == null ? "未知" : e.role); | ||
| 63 | + } | ||
| 64 | + Object old = redis.opsForHash().get(key, "entities"); | ||
| 65 | + if (old != null) { | ||
| 66 | + JsonNode arr = mapper.readTree(old.toString()); | ||
| 67 | + for (JsonNode n : arr) { | ||
| 68 | + if (merged.size() >= MAX_ENTITIES) break; | ||
| 69 | + boolean dup = false; | ||
| 70 | + for (JsonNode m : merged) { | ||
| 71 | + if (m.path("value").asText().equals(n.path("value").asText()) | ||
| 72 | + && m.path("role").asText().equals(n.path("role").asText())) { | ||
| 73 | + dup = true; | ||
| 74 | + break; | ||
| 75 | + } | ||
| 76 | + } | ||
| 77 | + if (!dup) merged.add(n); | ||
| 78 | + } | ||
| 79 | + } | ||
| 80 | + redis.opsForHash().put(key, "entities", mapper.writeValueAsString(merged)); | ||
| 81 | + redis.expire(key, TTL); | ||
| 82 | + } catch (Exception e) { | ||
| 83 | + log.warn("state mergeEntities failed (conv={}): {}", convId, e.getMessage()); | ||
| 84 | + } | ||
| 85 | + } | ||
| 86 | + | ||
| 87 | + /** 设置在办单据(entity=单据/实体类型,record=记录名/单号,stage=collecting|proposed|executed|failed|cancelled)。 */ | ||
| 88 | + public void setActiveDoc(String convId, String entity, String record, String opId, String stage) { | ||
| 89 | + try { | ||
| 90 | + String key = PREFIX + convId; | ||
| 91 | + ObjectNode doc = mapper.createObjectNode(); | ||
| 92 | + doc.put("entity", entity == null ? "" : entity); | ||
| 93 | + doc.put("record", record == null ? "" : record); | ||
| 94 | + doc.put("opId", opId == null ? "" : opId); | ||
| 95 | + doc.put("stage", stage == null ? "" : stage); | ||
| 96 | + redis.opsForHash().put(key, "doc", mapper.writeValueAsString(doc)); | ||
| 97 | + redis.expire(key, TTL); | ||
| 98 | + } catch (Exception e) { | ||
| 99 | + log.warn("state setActiveDoc failed (conv={}): {}", convId, e.getMessage()); | ||
| 100 | + } | ||
| 101 | + } | ||
| 102 | + | ||
| 103 | + /** 确认/取消后推进在办单据阶段(仅当 opId 匹配当前在办单据)。 */ | ||
| 104 | + public void updateDocStage(String convId, String opId, String stage) { | ||
| 105 | + if (convId == null || convId.isBlank() || opId == null || opId.isBlank()) { | ||
| 106 | + return; | ||
| 107 | + } | ||
| 108 | + try { | ||
| 109 | + String key = PREFIX + convId; | ||
| 110 | + Object old = redis.opsForHash().get(key, "doc"); | ||
| 111 | + if (old == null) { | ||
| 112 | + return; | ||
| 113 | + } | ||
| 114 | + ObjectNode doc = (ObjectNode) mapper.readTree(old.toString()); | ||
| 115 | + if (!opId.equals(doc.path("opId").asText(""))) { | ||
| 116 | + return; | ||
| 117 | + } | ||
| 118 | + doc.put("stage", stage); | ||
| 119 | + redis.opsForHash().put(key, "doc", mapper.writeValueAsString(doc)); | ||
| 120 | + } catch (Exception e) { | ||
| 121 | + log.warn("state updateDocStage failed (conv={}): {}", convId, e.getMessage()); | ||
| 122 | + } | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + /** 状态摘要(一行中文),空状态返回 ""。喂意图门 + 附在 agent 用户消息尾部。 */ | ||
| 126 | + public String digest(String convId) { | ||
| 127 | + try { | ||
| 128 | + String key = PREFIX + convId; | ||
| 129 | + Object intent = redis.opsForHash().get(key, "intent"); | ||
| 130 | + Object danju = redis.opsForHash().get(key, "danju"); | ||
| 131 | + Object doc = redis.opsForHash().get(key, "doc"); | ||
| 132 | + Object entities = redis.opsForHash().get(key, "entities"); | ||
| 133 | + StringBuilder sb = new StringBuilder(); | ||
| 134 | + if (intent != null && !intent.toString().isBlank()) { | ||
| 135 | + sb.append("上轮意图=").append(intent); | ||
| 136 | + if (danju != null && !danju.toString().isBlank()) { | ||
| 137 | + sb.append("(").append(danju).append(")"); | ||
| 138 | + } | ||
| 139 | + } | ||
| 140 | + if (doc != null) { | ||
| 141 | + JsonNode d = mapper.readTree(doc.toString()); | ||
| 142 | + String ent = d.path("entity").asText(""); | ||
| 143 | + String rec = d.path("record").asText(""); | ||
| 144 | + String stage = d.path("stage").asText(""); | ||
| 145 | + if (!ent.isBlank() || !rec.isBlank()) { | ||
| 146 | + if (sb.length() > 0) sb.append(";"); | ||
| 147 | + sb.append("在办单据=").append(ent); | ||
| 148 | + if (!rec.isBlank()) sb.append("【").append(rec).append("】"); | ||
| 149 | + if (!stage.isBlank()) sb.append("(").append(stageZh(stage)).append(")"); | ||
| 150 | + } | ||
| 151 | + } | ||
| 152 | + if (entities != null) { | ||
| 153 | + JsonNode arr = mapper.readTree(entities.toString()); | ||
| 154 | + StringBuilder es = new StringBuilder(); | ||
| 155 | + for (JsonNode n : arr) { | ||
| 156 | + if (es.length() > 0) es.append("、"); | ||
| 157 | + es.append(n.path("role").asText("未知")).append("=").append(n.path("value").asText("")); | ||
| 158 | + } | ||
| 159 | + if (es.length() > 0) { | ||
| 160 | + if (sb.length() > 0) sb.append(";"); | ||
| 161 | + sb.append("最近实体=").append(es); | ||
| 162 | + } | ||
| 163 | + } | ||
| 164 | + return sb.toString(); | ||
| 165 | + } catch (Exception e) { | ||
| 166 | + return ""; | ||
| 167 | + } | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | + private static String stageZh(String stage) { | ||
| 171 | + switch (stage) { | ||
| 172 | + case "collecting": return "填表中"; | ||
| 173 | + case "proposed": return "待确认"; | ||
| 174 | + case "executed": return "已执行"; | ||
| 175 | + case "failed": return "执行失败"; | ||
| 176 | + case "cancelled": return "已取消"; | ||
| 177 | + default: return stage; | ||
| 178 | + } | ||
| 179 | + } | ||
| 180 | + | ||
| 181 | + public void delete(String convId) { | ||
| 182 | + try { | ||
| 183 | + redis.delete(PREFIX + convId); | ||
| 184 | + } catch (Exception ignore) { | ||
| 185 | + } | ||
| 186 | + } | ||
| 187 | +} |
src/main/java/com/xly/web/AgentChatController.java
| @@ -6,12 +6,15 @@ import com.xly.agent.AgentIdentity; | @@ -6,12 +6,15 @@ import com.xly.agent.AgentIdentity; | ||
| 6 | import com.xly.agent.Intent; | 6 | import com.xly.agent.Intent; |
| 7 | import com.xly.agent.ReActAgent; | 7 | import com.xly.agent.ReActAgent; |
| 8 | import com.xly.config.AgentFactory; | 8 | import com.xly.config.AgentFactory; |
| 9 | +import com.xly.config.RedisChatMemoryStore; | ||
| 9 | import com.xly.service.AuthzService; | 10 | import com.xly.service.AuthzService; |
| 10 | import com.xly.service.ConversationService; | 11 | import com.xly.service.ConversationService; |
| 11 | import com.xly.service.FormResolverService; | 12 | import com.xly.service.FormResolverService; |
| 12 | import com.xly.service.IntentService; | 13 | import com.xly.service.IntentService; |
| 14 | +import com.xly.service.LedgerService; | ||
| 13 | import com.xly.service.OpService; | 15 | import com.xly.service.OpService; |
| 14 | import com.xly.service.SlotFillService; | 16 | import com.xly.service.SlotFillService; |
| 17 | +import com.xly.service.StateService; | ||
| 15 | import com.xly.tool.FormCollectTool; | 18 | import com.xly.tool.FormCollectTool; |
| 16 | import dev.langchain4j.service.TokenStream; | 19 | import dev.langchain4j.service.TokenStream; |
| 17 | import dev.langchain4j.service.tool.ToolExecution; | 20 | import dev.langchain4j.service.tool.ToolExecution; |
| @@ -31,6 +34,9 @@ import java.util.Map; | @@ -31,6 +34,9 @@ import java.util.Map; | ||
| 31 | import java.util.Set; | 34 | import java.util.Set; |
| 32 | import java.util.concurrent.ExecutorService; | 35 | import java.util.concurrent.ExecutorService; |
| 33 | import java.util.concurrent.Executors; | 36 | import java.util.concurrent.Executors; |
| 37 | +import java.util.concurrent.atomic.AtomicBoolean; | ||
| 38 | +import java.util.concurrent.atomic.AtomicInteger; | ||
| 39 | +import java.util.regex.Pattern; | ||
| 34 | 40 | ||
| 35 | /** | 41 | /** |
| 36 | * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 | 42 | * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 |
| @@ -56,6 +62,8 @@ public class AgentChatController { | @@ -56,6 +62,8 @@ public class AgentChatController { | ||
| 56 | private static final Set<String> WRITE_TOOLS = Set.of("proposeWrite"); | 62 | private static final Set<String> WRITE_TOOLS = Set.of("proposeWrite"); |
| 57 | /** 前端 collectForm 表单提交后拼出的消息带此标记 → 本轮直接走「写」执行 proposeWrite(action=create)。 */ | 63 | /** 前端 collectForm 表单提交后拼出的消息带此标记 → 本轮直接走「写」执行 proposeWrite(action=create)。 */ |
| 58 | private static final String FORM_SUBMIT_MARK = "proposeWrite(action=create)"; | 64 | private static final String FORM_SUBMIT_MARK = "proposeWrite(action=create)"; |
| 65 | + /** 反编造护栏:agent 声称「已生成/已完成」写操作的说法(无 proposeWrite 提议时要纠正)。 */ | ||
| 66 | + private static final Pattern WRITE_CLAIM = Pattern.compile("已(为您?|经)?(生成|提交|完成|写入|新增|修改|作废|审核)"); | ||
| 59 | 67 | ||
| 60 | private final AgentFactory agentFactory; | 68 | private final AgentFactory agentFactory; |
| 61 | private final AuthzService authz; | 69 | private final AuthzService authz; |
| @@ -65,12 +73,16 @@ public class AgentChatController { | @@ -65,12 +73,16 @@ public class AgentChatController { | ||
| 65 | private final IntentService intentService; | 73 | private final IntentService intentService; |
| 66 | private final SlotFillService slotFill; | 74 | private final SlotFillService slotFill; |
| 67 | private final FormResolverService resolver; | 75 | private final FormResolverService resolver; |
| 76 | + private final LedgerService ledger; | ||
| 77 | + private final StateService state; | ||
| 78 | + private final RedisChatMemoryStore memoryStore; | ||
| 68 | private final ExecutorService exec = Executors.newCachedThreadPool(); | 79 | private final ExecutorService exec = Executors.newCachedThreadPool(); |
| 69 | 80 | ||
| 70 | public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper, | 81 | public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper, |
| 71 | ConversationService conversations, OpService ops, | 82 | ConversationService conversations, OpService ops, |
| 72 | IntentService intentService, SlotFillService slotFill, | 83 | IntentService intentService, SlotFillService slotFill, |
| 73 | - FormResolverService resolver) { | 84 | + FormResolverService resolver, LedgerService ledger, |
| 85 | + StateService state, RedisChatMemoryStore memoryStore) { | ||
| 74 | this.agentFactory = agentFactory; | 86 | this.agentFactory = agentFactory; |
| 75 | this.authz = authz; | 87 | this.authz = authz; |
| 76 | this.mapper = mapper; | 88 | this.mapper = mapper; |
| @@ -79,6 +91,9 @@ public class AgentChatController { | @@ -79,6 +91,9 @@ public class AgentChatController { | ||
| 79 | this.intentService = intentService; | 91 | this.intentService = intentService; |
| 80 | this.slotFill = slotFill; | 92 | this.slotFill = slotFill; |
| 81 | this.resolver = resolver; | 93 | this.resolver = resolver; |
| 94 | + this.ledger = ledger; | ||
| 95 | + this.state = state; | ||
| 96 | + this.memoryStore = memoryStore; | ||
| 82 | } | 97 | } |
| 83 | 98 | ||
| 84 | public static class ChatReq { | 99 | public static class ChatReq { |
| @@ -101,6 +116,7 @@ public class AgentChatController { | @@ -101,6 +116,7 @@ public class AgentChatController { | ||
| 101 | : ((req.userid == null ? "anon" : req.userid) + ":default"); | 116 | : ((req.userid == null ? "anon" : req.userid) + ":default"); |
| 102 | 117 | ||
| 103 | conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput); | 118 | conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput); |
| 119 | + ledger.append(convId, "user", Map.of("text", userInput)); | ||
| 104 | final AgentIdentity identity = resolveIdentity(req); | 120 | final AgentIdentity identity = resolveIdentity(req); |
| 105 | 121 | ||
| 106 | exec.submit(() -> { | 122 | exec.submit(() -> { |
| @@ -119,11 +135,14 @@ public class AgentChatController { | @@ -119,11 +135,14 @@ public class AgentChatController { | ||
| 119 | private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) { | 135 | private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) { |
| 120 | // 0) 表单提交 → 直接执行 proposeWrite(action=create),不再重新分类。 | 136 | // 0) 表单提交 → 直接执行 proposeWrite(action=create),不再重新分类。 |
| 121 | if (userInput.contains(FORM_SUBMIT_MARK)) { | 137 | if (userInput.contains(FORM_SUBMIT_MARK)) { |
| 122 | - runAgent(emitter, convId, identity, userInput); | 138 | + runAgent(emitter, convId, identity, userInput, false); |
| 123 | return; | 139 | return; |
| 124 | } | 140 | } |
| 125 | - // 1) 意图门(失败时返回 其他,走兜底)。 | ||
| 126 | - Intent it = intentService.classify(userInput); | 141 | + // 1) 意图门(带上一轮留下的状态槽;失败时返回 其他,走兜底)。 |
| 142 | + String digest = state.digest(convId); | ||
| 143 | + Intent it = intentService.classify(userInput, digest); | ||
| 144 | + state.recordIntent(convId, it.intent, it.danju); | ||
| 145 | + state.mergeEntities(convId, it.entities); | ||
| 127 | log.info("intent(conv={}): {} / {} / entities={} / missing={}", | 146 | log.info("intent(conv={}): {} / {} / entities={} / missing={}", |
| 128 | convId, it.intent, it.danju, it.describeEntities(), it.missing); | 147 | convId, it.intent, it.danju, it.describeEntities(), it.missing); |
| 129 | 148 | ||
| @@ -133,20 +152,28 @@ public class AgentChatController { | @@ -133,20 +152,28 @@ public class AgentChatController { | ||
| 133 | return; | 152 | return; |
| 134 | } | 153 | } |
| 135 | // 无法确定性建表单 → 交给 agent 处理(可能需要它先问清单据类型)。 | 154 | // 无法确定性建表单 → 交给 agent 处理(可能需要它先问清单据类型)。 |
| 136 | - runAgent(emitter, convId, identity, ground(userInput, it)); | 155 | + runAgent(emitter, convId, identity, withState(ground(userInput, it), digest), false); |
| 137 | return; | 156 | return; |
| 138 | case Intent.OPERATE: | 157 | case Intent.OPERATE: |
| 139 | - handleWrite(emitter, convId, identity, userInput, it); | 158 | + handleWrite(emitter, convId, identity, userInput, it, digest); |
| 140 | return; | 159 | return; |
| 141 | case Intent.QUERY: | 160 | case Intent.QUERY: |
| 142 | - runAgent(emitter, convId, identity, ground(userInput, it)); | 161 | + runAgent(emitter, convId, identity, withState(ground(userInput, it), digest), true); |
| 143 | return; | 162 | return; |
| 144 | default: | 163 | default: |
| 145 | // 其他/分类失败:原文交给 agent,尽量不丢能力。 | 164 | // 其他/分类失败:原文交给 agent,尽量不丢能力。 |
| 146 | - runAgent(emitter, convId, identity, userInput); | 165 | + runAgent(emitter, convId, identity, withState(userInput, digest), false); |
| 147 | } | 166 | } |
| 148 | } | 167 | } |
| 149 | 168 | ||
| 169 | + /** 状态槽注入在用户消息尾部(空状态时原样返回,保持 KV 前缀稳定)。 */ | ||
| 170 | + private static String withState(String text, String digest) { | ||
| 171 | + if (digest == null || digest.isBlank()) { | ||
| 172 | + return text; | ||
| 173 | + } | ||
| 174 | + return text + "\n\n(会话状态,仅供参考:" + digest + ")"; | ||
| 175 | + } | ||
| 176 | + | ||
| 150 | /** | 177 | /** |
| 151 | * 确定性「新增」:解析目标表单 → 受约束槽位填充 → 弹 collectForm 表单。全程不经 LLM 选工具, | 178 | * 确定性「新增」:解析目标表单 → 受约束槽位填充 → 弹 collectForm 表单。全程不经 LLM 选工具, |
| 152 | * 因此「纸盒」这类产品名不可能被塞进客户字段。返回 false 表示无法处理(交回 route 兜底)。 | 179 | * 因此「纸盒」这类产品名不可能被塞进客户字段。返回 false 表示无法处理(交回 route 兜底)。 |
| @@ -172,7 +199,11 @@ public class AgentChatController { | @@ -172,7 +199,11 @@ public class AgentChatController { | ||
| 172 | JsonNode r = mapper.readTree(payload); | 199 | JsonNode r = mapper.readTree(payload); |
| 173 | if ("form_collect".equals(r.path("type").asText(""))) { | 200 | if ("form_collect".equals(r.path("type").asText(""))) { |
| 174 | sendEvent(emitter, mapper.convertValue(r, Map.class)); | 201 | sendEvent(emitter, mapper.convertValue(r, Map.class)); |
| 175 | - send(emitter, "token", "请在下方表单里填写(客户/产品从下拉里选真实数据),填完点【提交】。"); | 202 | + String hint = "请在下方表单里填写(客户/产品从下拉里选真实数据),填完点【提交】。"; |
| 203 | + send(emitter, "token", hint); | ||
| 204 | + ledger.append(convId, "form", Map.of("entity", entity, "message", hint)); | ||
| 205 | + state.setActiveDoc(convId, entity, "", "", "collecting"); | ||
| 206 | + appendMemoryTurn(convId, userInput, "已为「" + entity + "」弹出新建表单,等待用户填写提交。"); | ||
| 176 | send(emitter, "done", ""); | 207 | send(emitter, "done", ""); |
| 177 | emitter.complete(); | 208 | emitter.complete(); |
| 178 | return true; | 209 | return true; |
| @@ -181,6 +212,8 @@ public class AgentChatController { | @@ -181,6 +212,8 @@ public class AgentChatController { | ||
| 181 | String err = r.path("error").asText(""); | 212 | String err = r.path("error").asText(""); |
| 182 | if (!err.isBlank()) { | 213 | if (!err.isBlank()) { |
| 183 | send(emitter, "token", err); | 214 | send(emitter, "token", err); |
| 215 | + ledger.append(convId, "assistant", Map.of("text", err)); | ||
| 216 | + appendMemoryTurn(convId, userInput, err); | ||
| 184 | send(emitter, "done", ""); | 217 | send(emitter, "done", ""); |
| 185 | emitter.complete(); | 218 | emitter.complete(); |
| 186 | return true; | 219 | return true; |
| @@ -191,14 +224,58 @@ public class AgentChatController { | @@ -191,14 +224,58 @@ public class AgentChatController { | ||
| 191 | return false; | 224 | return false; |
| 192 | } | 225 | } |
| 193 | 226 | ||
| 194 | - /** 运行 ReAct agent,把流式回调转成 SSE。 */ | ||
| 195 | - private void runAgent(SseEmitter emitter, String convId, AgentIdentity identity, String text) { | 227 | + /** 确定性路径不经过 LLM 记忆——把这轮 用户话+系统答复 补进对话记忆,修补记忆空洞。 */ |
| 228 | + private void appendMemoryTurn(String convId, String userText, String aiText) { | ||
| 229 | + try { | ||
| 230 | + memoryStore.appendTurn(convId, userText, aiText); | ||
| 231 | + } catch (Exception e) { | ||
| 232 | + log.warn("append memory turn failed (conv={}): {}", convId, e.getMessage()); | ||
| 233 | + } | ||
| 234 | + } | ||
| 235 | + | ||
| 236 | + /** 运行 ReAct agent,把流式回调转成 SSE。queryGuard=true 时启用查询反编造护栏。 */ | ||
| 237 | + private void runAgent(SseEmitter emitter, String convId, AgentIdentity identity, | ||
| 238 | + String text, boolean queryGuard) { | ||
| 239 | + runAgentAttempt(emitter, convId, identity, text, queryGuard, true); | ||
| 240 | + } | ||
| 241 | + | ||
| 242 | + /** | ||
| 243 | + * 反编造护栏(代码层——实测 Ollama 对 tool_choice=required 不硬执行): | ||
| 244 | + * 查询轮**零工具调用**却答出数字 → 重试一次强制先查数,仍复发则标注「未经核实」; | ||
| 245 | + * 非查询轮声称「已生成/已完成」但没真正 proposeWrite → 附纠正提示(无提议卡片即未生效)。 | ||
| 246 | + */ | ||
| 247 | + private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity, | ||
| 248 | + String text, boolean queryGuard, boolean allowRetry) { | ||
| 196 | try { | 249 | try { |
| 197 | ReActAgent agent = agentFactory.build(identity); | 250 | ReActAgent agent = agentFactory.build(identity); |
| 251 | + AtomicInteger toolCalls = new AtomicInteger(); | ||
| 252 | + AtomicBoolean proposed = new AtomicBoolean(false); | ||
| 198 | TokenStream ts = agent.chat(convId, text); | 253 | TokenStream ts = agent.chat(convId, text); |
| 199 | ts.onPartialResponse(token -> send(emitter, "token", token)) | 254 | ts.onPartialResponse(token -> send(emitter, "token", token)) |
| 200 | - .onToolExecuted(te -> handleToolExecuted(emitter, convId, te)) | 255 | + .onToolExecuted(te -> { |
| 256 | + toolCalls.incrementAndGet(); | ||
| 257 | + handleToolExecuted(emitter, convId, te, proposed); | ||
| 258 | + }) | ||
| 201 | .onCompleteResponse(resp -> { | 259 | .onCompleteResponse(resp -> { |
| 260 | + String answer = resp == null || resp.aiMessage() == null || resp.aiMessage().text() == null | ||
| 261 | + ? "" : resp.aiMessage().text(); | ||
| 262 | + if (queryGuard && toolCalls.get() == 0 && hasDigits(answer)) { | ||
| 263 | + if (allowRetry) { | ||
| 264 | + log.warn("anti-fab retry (conv={}): zero tools + digits", convId); | ||
| 265 | + send(emitter, "reset", ""); | ||
| 266 | + runAgentAttempt(emitter, convId, identity, | ||
| 267 | + "你上一条回答没有调用任何工具、数字疑似编造。请先用工具查询真实数据,再重新回答这个问题:" + text, | ||
| 268 | + true, false); | ||
| 269 | + return; | ||
| 270 | + } | ||
| 271 | + send(emitter, "token", "\n\n⚠️ 注意:以上数字未能经系统数据核实,仅供参考。"); | ||
| 272 | + } | ||
| 273 | + if (!queryGuard && !proposed.get() && WRITE_CLAIM.matcher(answer).find()) { | ||
| 274 | + send(emitter, "token", "\n\n⚠️ 系统提示:本条回复没有真正生成待确认操作(没有出现提议卡片即未生效),请重新描述一次您要做的操作。"); | ||
| 275 | + } | ||
| 276 | + if (!answer.isBlank()) { | ||
| 277 | + ledger.append(convId, "assistant", Map.of("text", answer)); | ||
| 278 | + } | ||
| 202 | send(emitter, "done", ""); | 279 | send(emitter, "done", ""); |
| 203 | emitter.complete(); | 280 | emitter.complete(); |
| 204 | }) | 281 | }) |
| @@ -215,6 +292,18 @@ public class AgentChatController { | @@ -215,6 +292,18 @@ public class AgentChatController { | ||
| 215 | } | 292 | } |
| 216 | } | 293 | } |
| 217 | 294 | ||
| 295 | + private static boolean hasDigits(String s) { | ||
| 296 | + if (s == null) { | ||
| 297 | + return false; | ||
| 298 | + } | ||
| 299 | + for (int i = 0; i < s.length(); i++) { | ||
| 300 | + if (Character.isDigit(s.charAt(i))) { | ||
| 301 | + return true; | ||
| 302 | + } | ||
| 303 | + } | ||
| 304 | + return false; | ||
| 305 | + } | ||
| 306 | + | ||
| 218 | /** 把意图门结果作为 grounding 附在用户消息后,稳住下游 agent 的选工具与实体理解。 */ | 307 | /** 把意图门结果作为 grounding 附在用户消息后,稳住下游 agent 的选工具与实体理解。 */ |
| 219 | private String ground(String userInput, Intent it) { | 308 | private String ground(String userInput, Intent it) { |
| 220 | StringBuilder g = new StringBuilder(userInput); | 309 | StringBuilder g = new StringBuilder(userInput); |
| @@ -242,8 +331,8 @@ public class AgentChatController { | @@ -242,8 +331,8 @@ public class AgentChatController { | ||
| 242 | * 缺了就**确定性问一次**并停下——两头都不进失控循环。 | 331 | * 缺了就**确定性问一次**并停下——两头都不进失控循环。 |
| 243 | */ | 332 | */ |
| 244 | private void handleWrite(SseEmitter emitter, String convId, AgentIdentity identity, | 333 | private void handleWrite(SseEmitter emitter, String convId, AgentIdentity identity, |
| 245 | - String userInput, Intent it) { | ||
| 246 | - Intent.WriteSlots w = intentService.extractWrite(userInput); | 334 | + String userInput, Intent it, String digest) { |
| 335 | + Intent.WriteSlots w = intentService.extractWrite(userInput, digest); | ||
| 247 | log.info("write-slots(conv={}): entity={} record={} field={} newValue={}", | 336 | log.info("write-slots(conv={}): entity={} record={} field={} newValue={}", |
| 248 | convId, w.entityType, w.record, w.field, w.newValue); | 337 | convId, w.entityType, w.record, w.field, w.newValue); |
| 249 | 338 | ||
| @@ -256,11 +345,11 @@ public class AgentChatController { | @@ -256,11 +345,11 @@ public class AgentChatController { | ||
| 256 | if (isBlank(w.record)) need.add("要修改哪条记录(名称/单号)"); | 345 | if (isBlank(w.record)) need.add("要修改哪条记录(名称/单号)"); |
| 257 | if (isBlank(w.newValue)) need.add("改成什么新值"); | 346 | if (isBlank(w.newValue)) need.add("改成什么新值"); |
| 258 | if (!need.isEmpty()) { | 347 | if (!need.isEmpty()) { |
| 259 | - clarifyWrite(emitter, "修改", w.record, need); | 348 | + clarifyWrite(emitter, convId, userInput, "修改", w.record, need); |
| 260 | return; | 349 | return; |
| 261 | } | 350 | } |
| 262 | } else if (isBlank(w.record)) { | 351 | } else if (isBlank(w.record)) { |
| 263 | - clarifyWrite(emitter, actionVerb(action), "", | 352 | + clarifyWrite(emitter, convId, userInput, actionVerb(action), "", |
| 264 | java.util.List.of("要" + actionVerb(action) + "哪条记录(名称/单号)")); | 353 | java.util.List.of("要" + actionVerb(action) + "哪条记录(名称/单号)")); |
| 265 | return; | 354 | return; |
| 266 | } | 355 | } |
| @@ -268,24 +357,32 @@ public class AgentChatController { | @@ -268,24 +357,32 @@ public class AgentChatController { | ||
| 268 | // 确定性调用 proposeWrite(不经 LLM 选工具,避免它反问/选错),直接渲染结果 | 357 | // 确定性调用 proposeWrite(不经 LLM 选工具,避免它反问/选错),直接渲染结果 |
| 269 | String result = agentFactory.proposeWriteTool(identity) | 358 | String result = agentFactory.proposeWriteTool(identity) |
| 270 | .proposeWrite(action, ent, w.record, w.field, w.newValue, null); | 359 | .proposeWrite(action, ent, w.record, w.field, w.newValue, null); |
| 271 | - emitWriteResult(emitter, convId, result); | 360 | + emitWriteResult(emitter, convId, userInput, ent, w.record, result); |
| 272 | } | 361 | } |
| 273 | 362 | ||
| 274 | /** 把 proposeWrite 的返回渲染成 SSE:有 opId → 写提议卡;否则 → 文字反馈(定位失败/多条匹配等)。 */ | 363 | /** 把 proposeWrite 的返回渲染成 SSE:有 opId → 写提议卡;否则 → 文字反馈(定位失败/多条匹配等)。 */ |
| 275 | - private void emitWriteResult(SseEmitter emitter, String convId, String result) { | 364 | + private void emitWriteResult(SseEmitter emitter, String convId, String userInput, |
| 365 | + String ent, String record, String result) { | ||
| 276 | try { | 366 | try { |
| 277 | JsonNode r = mapper.readTree(result); | 367 | JsonNode r = mapper.readTree(result); |
| 278 | String opId = r.path("opId").asText(null); | 368 | String opId = r.path("opId").asText(null); |
| 279 | if (opId != null && !opId.isBlank()) { | 369 | if (opId != null && !opId.isBlank()) { |
| 280 | ops.attachConversation(opId, convId); | 370 | ops.attachConversation(opId, convId); |
| 371 | + String summary = r.path("summary").asText(""); | ||
| 281 | Map<String, Object> card = new LinkedHashMap<>(); | 372 | Map<String, Object> card = new LinkedHashMap<>(); |
| 282 | card.put("type", "write_proposal"); | 373 | card.put("type", "write_proposal"); |
| 283 | card.put("opId", opId); | 374 | card.put("opId", opId); |
| 284 | - card.put("summary", r.path("summary").asText("")); | 375 | + card.put("summary", summary); |
| 285 | sendEvent(emitter, card); | 376 | sendEvent(emitter, card); |
| 286 | send(emitter, "token", r.path("message").asText("已生成待确认操作,请点【确认】。")); | 377 | send(emitter, "token", r.path("message").asText("已生成待确认操作,请点【确认】。")); |
| 378 | + ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary)); | ||
| 379 | + state.setActiveDoc(convId, ent, record, opId, "proposed"); | ||
| 380 | + appendMemoryTurn(convId, userInput, "已生成待确认提议:" + summary + "(等待用户点确认/取消)"); | ||
| 287 | } else { | 381 | } else { |
| 288 | - send(emitter, "token", r.path("error").asText("无法完成该操作。")); | 382 | + String err = r.path("error").asText("无法完成该操作。"); |
| 383 | + send(emitter, "token", err); | ||
| 384 | + ledger.append(convId, "assistant", Map.of("text", err)); | ||
| 385 | + appendMemoryTurn(convId, userInput, err); | ||
| 289 | } | 386 | } |
| 290 | } catch (Exception e) { | 387 | } catch (Exception e) { |
| 291 | send(emitter, "error", "服务异常:" + e.getMessage()); | 388 | send(emitter, "error", "服务异常:" + e.getMessage()); |
| @@ -326,10 +423,14 @@ public class AgentChatController { | @@ -326,10 +423,14 @@ public class AgentChatController { | ||
| 326 | } | 423 | } |
| 327 | } | 424 | } |
| 328 | 425 | ||
| 329 | - private void clarifyWrite(SseEmitter emitter, String verb, String record, java.util.List<String> need) { | 426 | + private void clarifyWrite(SseEmitter emitter, String convId, String userInput, |
| 427 | + String verb, String record, java.util.List<String> need) { | ||
| 330 | String who = isBlank(record) ? "" : ("(记录:" + record + ")"); | 428 | String who = isBlank(record) ? "" : ("(记录:" + record + ")"); |
| 331 | - send(emitter, "token", "要" + verb + who + ",我还需要您补充:" + String.join("、", need) | ||
| 332 | - + "。请一起告诉我,我再为你生成待确认的操作。"); | 429 | + String text = "要" + verb + who + ",我还需要您补充:" + String.join("、", need) |
| 430 | + + "。请一起告诉我,我再为你生成待确认的操作。"; | ||
| 431 | + send(emitter, "token", text); | ||
| 432 | + ledger.append(convId, "clarify", Map.of("text", text)); | ||
| 433 | + appendMemoryTurn(convId, userInput, text); | ||
| 333 | send(emitter, "done", ""); | 434 | send(emitter, "done", ""); |
| 334 | emitter.complete(); | 435 | emitter.complete(); |
| 335 | } | 436 | } |
| @@ -351,8 +452,8 @@ public class AgentChatController { | @@ -351,8 +452,8 @@ public class AgentChatController { | ||
| 351 | return authz.devIdentity(); | 452 | return authz.devIdentity(); |
| 352 | } | 453 | } |
| 353 | 454 | ||
| 354 | - /** 工具执行回调:清掉工具前旁白(reset);再按工具类型推对应卡片/控件事件。 */ | ||
| 355 | - private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te) { | 455 | + /** 工具执行回调:清掉工具前旁白(reset);按工具类型推对应卡片/控件事件;同步落账本/状态槽。 */ |
| 456 | + private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te, AtomicBoolean proposed) { | ||
| 356 | send(emitter, "reset", ""); | 457 | send(emitter, "reset", ""); |
| 357 | try { | 458 | try { |
| 358 | String toolName = te.request() == null ? "" : te.request().name(); | 459 | String toolName = te.request() == null ? "" : te.request().name(); |
| @@ -364,18 +465,37 @@ public class AgentChatController { | @@ -364,18 +465,37 @@ public class AgentChatController { | ||
| 364 | String opId = r.path("opId").asText(null); | 465 | String opId = r.path("opId").asText(null); |
| 365 | if (opId != null && !opId.isBlank()) { | 466 | if (opId != null && !opId.isBlank()) { |
| 366 | ops.attachConversation(opId, convId); | 467 | ops.attachConversation(opId, convId); |
| 468 | + String summary = r.path("summary").asText(""); | ||
| 367 | Map<String, Object> card = new LinkedHashMap<>(); | 469 | Map<String, Object> card = new LinkedHashMap<>(); |
| 368 | card.put("type", "write_proposal"); | 470 | card.put("type", "write_proposal"); |
| 369 | card.put("opId", opId); | 471 | card.put("opId", opId); |
| 370 | - card.put("summary", r.path("summary").asText("")); | 472 | + card.put("summary", summary); |
| 371 | sendEvent(emitter, card); | 473 | sendEvent(emitter, card); |
| 474 | + proposed.set(true); | ||
| 475 | + ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary)); | ||
| 476 | + state.setActiveDoc(convId, "", summary, opId, "proposed"); | ||
| 372 | } | 477 | } |
| 373 | } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) { | 478 | } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) { |
| 374 | JsonNode r = mapper.readTree(te.result()); | 479 | JsonNode r = mapper.readTree(te.result()); |
| 375 | String type = r.path("type").asText(""); | 480 | String type = r.path("type").asText(""); |
| 376 | - if ("question".equals(type) || "form_collect".equals(type)) { | 481 | + if ("question".equals(type)) { |
| 377 | sendEvent(emitter, mapper.convertValue(r, Map.class)); | 482 | sendEvent(emitter, mapper.convertValue(r, Map.class)); |
| 483 | + ledger.append(convId, "question", Map.of( | ||
| 484 | + "question", r.path("question").asText(""), | ||
| 485 | + "options", mapper.convertValue(r.path("options"), List.class))); | ||
| 486 | + } else if ("form_collect".equals(type)) { | ||
| 487 | + sendEvent(emitter, mapper.convertValue(r, Map.class)); | ||
| 488 | + String entity = r.path("entity").asText(""); | ||
| 489 | + ledger.append(convId, "form", Map.of("entity", entity, | ||
| 490 | + "message", r.path("message").asText(""))); | ||
| 491 | + state.setActiveDoc(convId, entity, "", "", "collecting"); | ||
| 492 | + } | ||
| 493 | + } else { | ||
| 494 | + String digest = te.result().replace('\n', ' ').trim(); | ||
| 495 | + if (digest.length() > 100) { | ||
| 496 | + digest = digest.substring(0, 100) + "…"; | ||
| 378 | } | 497 | } |
| 498 | + ledger.append(convId, "tool", Map.of("name", toolName, "digest", digest)); | ||
| 379 | } | 499 | } |
| 380 | } catch (Exception e) { | 500 | } catch (Exception e) { |
| 381 | log.warn("handle tool result failed ({})", te.request() == null ? "?" : te.request().name(), e); | 501 | log.warn("handle tool result failed ({})", te.request() == null ? "?" : te.request().name(), e); |
src/main/java/com/xly/web/OpController.java
| @@ -2,9 +2,12 @@ package com.xly.web; | @@ -2,9 +2,12 @@ package com.xly.web; | ||
| 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 com.xly.config.RedisChatMemoryStore; | ||
| 5 | import com.xly.service.AuditService; | 6 | import com.xly.service.AuditService; |
| 6 | import com.xly.service.ErpClient; | 7 | import com.xly.service.ErpClient; |
| 8 | +import com.xly.service.LedgerService; | ||
| 7 | import com.xly.service.OpService; | 9 | import com.xly.service.OpService; |
| 10 | +import com.xly.service.StateService; | ||
| 8 | import org.slf4j.Logger; | 11 | import org.slf4j.Logger; |
| 9 | import org.slf4j.LoggerFactory; | 12 | import org.slf4j.LoggerFactory; |
| 10 | import org.springframework.web.bind.annotation.GetMapping; | 13 | import org.springframework.web.bind.annotation.GetMapping; |
| @@ -42,16 +45,49 @@ public class OpController { | @@ -42,16 +45,49 @@ public class OpController { | ||
| 42 | private final ErpClient erp; | 45 | private final ErpClient erp; |
| 43 | private final AuditService audit; | 46 | private final AuditService audit; |
| 44 | private final ObjectMapper mapper; | 47 | private final ObjectMapper mapper; |
| 48 | + private final LedgerService ledger; | ||
| 49 | + private final StateService state; | ||
| 50 | + private final RedisChatMemoryStore memoryStore; | ||
| 45 | 51 | ||
| 46 | /** true=确认后委托 ERP 侧暂存执行器(/ai/execStaging,§10 生产路径);false=xlyAi 直连 ERP 通用写接口执行。 */ | 52 | /** true=确认后委托 ERP 侧暂存执行器(/ai/execStaging,§10 生产路径);false=xlyAi 直连 ERP 通用写接口执行。 */ |
| 47 | @org.springframework.beans.factory.annotation.Value("${erp.exec-staging.enabled:false}") | 53 | @org.springframework.beans.factory.annotation.Value("${erp.exec-staging.enabled:false}") |
| 48 | private boolean execStagingEnabled; | 54 | private boolean execStagingEnabled; |
| 49 | 55 | ||
| 50 | - public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper) { | 56 | + public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper, |
| 57 | + LedgerService ledger, StateService state, RedisChatMemoryStore memoryStore) { | ||
| 51 | this.ops = ops; | 58 | this.ops = ops; |
| 52 | this.erp = erp; | 59 | this.erp = erp; |
| 53 | this.audit = audit; | 60 | this.audit = audit; |
| 54 | this.mapper = mapper; | 61 | this.mapper = mapper; |
| 62 | + this.ledger = ledger; | ||
| 63 | + this.state = state; | ||
| 64 | + this.memoryStore = memoryStore; | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + /** 确认/取消的结果落账本+状态槽+对话记忆(记忆空洞修补:LLM 下轮就知道这单已执行/失败/取消)。 */ | ||
| 68 | + private void recordOutcome(String conv, String opId, String status, String msg, String description) { | ||
| 69 | + if (conv == null || conv.isBlank() || "null".equals(conv)) { | ||
| 70 | + return; | ||
| 71 | + } | ||
| 72 | + try { | ||
| 73 | + ledger.append(conv, "cancelled".equals(status) ? "cancel" : "confirm", Map.of( | ||
| 74 | + "opId", opId == null ? "" : opId, | ||
| 75 | + "status", status, | ||
| 76 | + "msg", msg == null ? "" : msg, | ||
| 77 | + "description", description == null ? "" : description)); | ||
| 78 | + state.updateDocStage(conv, opId, status); | ||
| 79 | + String note; | ||
| 80 | + if ("executed".equals(status)) { | ||
| 81 | + note = "(系统)用户已确认,操作执行成功:" + description; | ||
| 82 | + } else if ("cancelled".equals(status)) { | ||
| 83 | + note = "(系统)用户取消了该操作:" + description; | ||
| 84 | + } else { | ||
| 85 | + note = "(系统)用户确认后执行失败:" + description + (msg == null || msg.isBlank() ? "" : (",原因:" + msg)); | ||
| 86 | + } | ||
| 87 | + memoryStore.appendTurn(conv, null, note); | ||
| 88 | + } catch (Exception e) { | ||
| 89 | + log.warn("record op outcome failed (conv={}, op={}): {}", conv, opId, e.getMessage()); | ||
| 90 | + } | ||
| 55 | } | 91 | } |
| 56 | 92 | ||
| 57 | /** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */ | 93 | /** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */ |
| @@ -116,16 +152,19 @@ public class OpController { | @@ -116,16 +152,19 @@ public class OpController { | ||
| 116 | if (code == 1) { | 152 | if (code == 1) { |
| 117 | ops.setStatus(id, "executed", "操作成功"); | 153 | ops.setStatus(id, "executed", "操作成功"); |
| 118 | audit.log(uid, conv, "confirm", target, detail, true, "executed"); | 154 | audit.log(uid, conv, "confirm", target, detail, true, "executed"); |
| 155 | + recordOutcome(conv, id, "executed", null, detail); | ||
| 119 | return result("executed", "已执行:" + op.get("sDescription"), op); | 156 | return result("executed", "已执行:" + op.get("sDescription"), op); |
| 120 | } | 157 | } |
| 121 | String msg = r.path("msg").asText("执行失败"); | 158 | String msg = r.path("msg").asText("执行失败"); |
| 122 | ops.setStatus(id, "failed", msg); | 159 | ops.setStatus(id, "failed", msg); |
| 123 | audit.log(uid, conv, "confirm", target, detail, false, msg); | 160 | audit.log(uid, conv, "confirm", target, detail, false, msg); |
| 161 | + recordOutcome(conv, id, "failed", msg, detail); | ||
| 124 | return result("failed", msg, op); | 162 | return result("failed", msg, op); |
| 125 | } catch (Exception e) { | 163 | } catch (Exception e) { |
| 126 | log.warn("confirm op {} failed", id, e); | 164 | log.warn("confirm op {} failed", id, e); |
| 127 | ops.setStatus(id, "failed", e.getMessage()); | 165 | ops.setStatus(id, "failed", e.getMessage()); |
| 128 | audit.log(uid, conv, "confirm", target, detail, false, e.getMessage()); | 166 | audit.log(uid, conv, "confirm", target, detail, false, e.getMessage()); |
| 167 | + recordOutcome(conv, id, "failed", e.getMessage(), detail); | ||
| 129 | return result("failed", "执行异常:" + e.getMessage(), op); | 168 | return result("failed", "执行异常:" + e.getMessage(), op); |
| 130 | } | 169 | } |
| 131 | } | 170 | } |
| @@ -139,11 +178,13 @@ public class OpController { | @@ -139,11 +178,13 @@ public class OpController { | ||
| 139 | String msg = r.path("msg").asText(""); | 178 | String msg = r.path("msg").asText(""); |
| 140 | boolean ok = "executed".equals(st); | 179 | boolean ok = "executed".equals(st); |
| 141 | audit.log(uid, conv, "confirm", target, detail, ok, "execStaging:" + st + (msg.isEmpty() ? "" : (" " + msg))); | 180 | audit.log(uid, conv, "confirm", target, detail, ok, "execStaging:" + st + (msg.isEmpty() ? "" : (" " + msg))); |
| 181 | + recordOutcome(conv, id, ok ? "executed" : "failed", msg, detail); | ||
| 142 | return result(ok ? "executed" : "failed", | 182 | return result(ok ? "executed" : "failed", |
| 143 | ok ? ("已执行:" + op.get("sDescription")) : (msg.isEmpty() ? "执行失败" : msg), op); | 183 | ok ? ("已执行:" + op.get("sDescription")) : (msg.isEmpty() ? "执行失败" : msg), op); |
| 144 | } catch (Exception e) { | 184 | } catch (Exception e) { |
| 145 | log.warn("execStaging confirm op {} failed", id, e); | 185 | log.warn("execStaging confirm op {} failed", id, e); |
| 146 | audit.log(uid, conv, "confirm", target, detail, false, e.getMessage()); | 186 | audit.log(uid, conv, "confirm", target, detail, false, e.getMessage()); |
| 187 | + recordOutcome(conv, id, "failed", e.getMessage(), detail); | ||
| 147 | return result("failed", "执行异常:" + e.getMessage(), op); | 188 | return result("failed", "执行异常:" + e.getMessage(), op); |
| 148 | } | 189 | } |
| 149 | } | 190 | } |
| @@ -157,6 +198,7 @@ public class OpController { | @@ -157,6 +198,7 @@ public class OpController { | ||
| 157 | audit.log(str(op.get("sUserId")), str(op.get("sConversationId")), "cancel", | 198 | audit.log(str(op.get("sUserId")), str(op.get("sConversationId")), "cancel", |
| 158 | str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId")), | 199 | str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId")), |
| 159 | str(op.get("sDescription")), true, "cancelled"); | 200 | str(op.get("sDescription")), true, "cancelled"); |
| 201 | + recordOutcome(str(op.get("sConversationId")), id, "cancelled", null, str(op.get("sDescription"))); | ||
| 160 | } | 202 | } |
| 161 | return result("cancelled", "已取消", op); | 203 | return result("cancelled", "已取消", op); |
| 162 | } | 204 | } |