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 | 12 | import com.xly.tool.InteractionTool; |
| 13 | 13 | import com.xly.tool.KgQueryTool; |
| 14 | 14 | import com.xly.tool.ProposeWriteTool; |
| 15 | -import dev.langchain4j.memory.chat.MessageWindowChatMemory; | |
| 15 | +import com.xly.agent.ProjectedChatMemory; | |
| 16 | 16 | import dev.langchain4j.model.chat.StreamingChatModel; |
| 17 | 17 | import dev.langchain4j.service.AiServices; |
| 18 | 18 | import org.springframework.beans.factory.annotation.Qualifier; |
| ... | ... | @@ -77,11 +77,8 @@ public class AgentFactory { |
| 77 | 77 | .streamingChatModel(streamingModel) |
| 78 | 78 | .tools(tools) |
| 79 | 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 | 82 | .systemMessageProvider(memoryId -> systemPromptService.prompt()) |
| 86 | 83 | .build(); |
| 87 | 84 | } | ... | ... |
src/main/java/com/xly/config/RedisChatMemoryStore.java
| 1 | 1 | package com.xly.config; |
| 2 | 2 | |
| 3 | +import dev.langchain4j.data.message.AiMessage; | |
| 3 | 4 | import dev.langchain4j.data.message.ChatMessage; |
| 4 | 5 | import dev.langchain4j.data.message.ChatMessageDeserializer; |
| 5 | 6 | import dev.langchain4j.data.message.ChatMessageSerializer; |
| 7 | +import dev.langchain4j.data.message.UserMessage; | |
| 6 | 8 | import dev.langchain4j.store.memory.chat.ChatMemoryStore; |
| 7 | 9 | import org.springframework.data.redis.core.StringRedisTemplate; |
| 8 | 10 | import org.springframework.stereotype.Component; |
| ... | ... | @@ -47,4 +49,16 @@ public class RedisChatMemoryStore implements ChatMemoryStore { |
| 47 | 49 | public void deleteMessages(Object memoryId) { |
| 48 | 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 | 27 | private final StringRedisTemplate redis; |
| 28 | 28 | private final RedisChatMemoryStore memoryStore; |
| 29 | 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 | 35 | this.redis = redis; |
| 33 | 36 | this.memoryStore = memoryStore; |
| 34 | 37 | this.mapper = mapper; |
| 38 | + this.ledger = ledger; | |
| 39 | + this.state = state; | |
| 35 | 40 | } |
| 36 | 41 | |
| 37 | 42 | /** 新建一个空会话,返回 convId。 */ |
| ... | ... | @@ -87,10 +92,34 @@ public class ConversationService { |
| 87 | 92 | public void delete(String userId, String convId) { |
| 88 | 93 | redis.opsForHash().delete(CONVS_KEY + userId, convId); |
| 89 | 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 | 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 | 123 | List<Map<String, String>> out = new ArrayList<>(); |
| 95 | 124 | for (ChatMessage m : memoryStore.getMessages(convId)) { |
| 96 | 125 | if (m instanceof UserMessage um && um.hasSingleText()) { |
| ... | ... | @@ -103,6 +132,20 @@ public class ConversationService { |
| 103 | 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 | 149 | private String deriveTitle(String s) { |
| 107 | 150 | if (s == null) { |
| 108 | 151 | return "新会话"; | ... | ... |
src/main/java/com/xly/service/IntentService.java
| ... | ... | @@ -47,11 +47,16 @@ public class IntentService { |
| 47 | 47 | |
| 48 | 48 | /** 意图门主入口。utterance 为空或模型失败时返回 其他。 */ |
| 49 | 49 | public Intent classify(String utterance) { |
| 50 | + return classify(utterance, null); | |
| 51 | + } | |
| 52 | + | |
| 53 | + /** 带会话状态摘要的分类:状态槽让「那张单子」这类跨轮指代可解。 */ | |
| 54 | + public Intent classify(String utterance, String stateDigest) { | |
| 50 | 55 | Intent out = new Intent(); |
| 51 | 56 | if (utterance == null || utterance.isBlank()) { |
| 52 | 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 | 60 | if (n == null) { |
| 56 | 61 | log.warn("intent classify fell back to 其他 (model unavailable)"); |
| 57 | 62 | return out; |
| ... | ... | @@ -88,11 +93,16 @@ public class IntentService { |
| 88 | 93 | |
| 89 | 94 | /** 抽取写操作槽位(修改/删除/审核用)。失败返回空槽位。 */ |
| 90 | 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 | 101 | Intent.WriteSlots w = new Intent.WriteSlots(); |
| 92 | 102 | if (utterance == null || utterance.isBlank()) { |
| 93 | 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 | 106 | if (n == null) { |
| 97 | 107 | return w; |
| 98 | 108 | } |
| ... | ... | @@ -116,6 +126,15 @@ public class IntentService { |
| 116 | 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 | 138 | private static String normalizeIntent(String s) { |
| 120 | 139 | if (s == null) return Intent.OTHER; |
| 121 | 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 | 6 | import com.xly.agent.Intent; |
| 7 | 7 | import com.xly.agent.ReActAgent; |
| 8 | 8 | import com.xly.config.AgentFactory; |
| 9 | +import com.xly.config.RedisChatMemoryStore; | |
| 9 | 10 | import com.xly.service.AuthzService; |
| 10 | 11 | import com.xly.service.ConversationService; |
| 11 | 12 | import com.xly.service.FormResolverService; |
| 12 | 13 | import com.xly.service.IntentService; |
| 14 | +import com.xly.service.LedgerService; | |
| 13 | 15 | import com.xly.service.OpService; |
| 14 | 16 | import com.xly.service.SlotFillService; |
| 17 | +import com.xly.service.StateService; | |
| 15 | 18 | import com.xly.tool.FormCollectTool; |
| 16 | 19 | import dev.langchain4j.service.TokenStream; |
| 17 | 20 | import dev.langchain4j.service.tool.ToolExecution; |
| ... | ... | @@ -31,6 +34,9 @@ import java.util.Map; |
| 31 | 34 | import java.util.Set; |
| 32 | 35 | import java.util.concurrent.ExecutorService; |
| 33 | 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 | 42 | * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 |
| ... | ... | @@ -56,6 +62,8 @@ public class AgentChatController { |
| 56 | 62 | private static final Set<String> WRITE_TOOLS = Set.of("proposeWrite"); |
| 57 | 63 | /** 前端 collectForm 表单提交后拼出的消息带此标记 → 本轮直接走「写」执行 proposeWrite(action=create)。 */ |
| 58 | 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 | 68 | private final AgentFactory agentFactory; |
| 61 | 69 | private final AuthzService authz; |
| ... | ... | @@ -65,12 +73,16 @@ public class AgentChatController { |
| 65 | 73 | private final IntentService intentService; |
| 66 | 74 | private final SlotFillService slotFill; |
| 67 | 75 | private final FormResolverService resolver; |
| 76 | + private final LedgerService ledger; | |
| 77 | + private final StateService state; | |
| 78 | + private final RedisChatMemoryStore memoryStore; | |
| 68 | 79 | private final ExecutorService exec = Executors.newCachedThreadPool(); |
| 69 | 80 | |
| 70 | 81 | public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper, |
| 71 | 82 | ConversationService conversations, OpService ops, |
| 72 | 83 | IntentService intentService, SlotFillService slotFill, |
| 73 | - FormResolverService resolver) { | |
| 84 | + FormResolverService resolver, LedgerService ledger, | |
| 85 | + StateService state, RedisChatMemoryStore memoryStore) { | |
| 74 | 86 | this.agentFactory = agentFactory; |
| 75 | 87 | this.authz = authz; |
| 76 | 88 | this.mapper = mapper; |
| ... | ... | @@ -79,6 +91,9 @@ public class AgentChatController { |
| 79 | 91 | this.intentService = intentService; |
| 80 | 92 | this.slotFill = slotFill; |
| 81 | 93 | this.resolver = resolver; |
| 94 | + this.ledger = ledger; | |
| 95 | + this.state = state; | |
| 96 | + this.memoryStore = memoryStore; | |
| 82 | 97 | } |
| 83 | 98 | |
| 84 | 99 | public static class ChatReq { |
| ... | ... | @@ -101,6 +116,7 @@ public class AgentChatController { |
| 101 | 116 | : ((req.userid == null ? "anon" : req.userid) + ":default"); |
| 102 | 117 | |
| 103 | 118 | conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput); |
| 119 | + ledger.append(convId, "user", Map.of("text", userInput)); | |
| 104 | 120 | final AgentIdentity identity = resolveIdentity(req); |
| 105 | 121 | |
| 106 | 122 | exec.submit(() -> { |
| ... | ... | @@ -119,11 +135,14 @@ public class AgentChatController { |
| 119 | 135 | private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) { |
| 120 | 136 | // 0) 表单提交 → 直接执行 proposeWrite(action=create),不再重新分类。 |
| 121 | 137 | if (userInput.contains(FORM_SUBMIT_MARK)) { |
| 122 | - runAgent(emitter, convId, identity, userInput); | |
| 138 | + runAgent(emitter, convId, identity, userInput, false); | |
| 123 | 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 | 146 | log.info("intent(conv={}): {} / {} / entities={} / missing={}", |
| 128 | 147 | convId, it.intent, it.danju, it.describeEntities(), it.missing); |
| 129 | 148 | |
| ... | ... | @@ -133,20 +152,28 @@ public class AgentChatController { |
| 133 | 152 | return; |
| 134 | 153 | } |
| 135 | 154 | // 无法确定性建表单 → 交给 agent 处理(可能需要它先问清单据类型)。 |
| 136 | - runAgent(emitter, convId, identity, ground(userInput, it)); | |
| 155 | + runAgent(emitter, convId, identity, withState(ground(userInput, it), digest), false); | |
| 137 | 156 | return; |
| 138 | 157 | case Intent.OPERATE: |
| 139 | - handleWrite(emitter, convId, identity, userInput, it); | |
| 158 | + handleWrite(emitter, convId, identity, userInput, it, digest); | |
| 140 | 159 | return; |
| 141 | 160 | case Intent.QUERY: |
| 142 | - runAgent(emitter, convId, identity, ground(userInput, it)); | |
| 161 | + runAgent(emitter, convId, identity, withState(ground(userInput, it), digest), true); | |
| 143 | 162 | return; |
| 144 | 163 | default: |
| 145 | 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 | 178 | * 确定性「新增」:解析目标表单 → 受约束槽位填充 → 弹 collectForm 表单。全程不经 LLM 选工具, |
| 152 | 179 | * 因此「纸盒」这类产品名不可能被塞进客户字段。返回 false 表示无法处理(交回 route 兜底)。 |
| ... | ... | @@ -172,7 +199,11 @@ public class AgentChatController { |
| 172 | 199 | JsonNode r = mapper.readTree(payload); |
| 173 | 200 | if ("form_collect".equals(r.path("type").asText(""))) { |
| 174 | 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 | 207 | send(emitter, "done", ""); |
| 177 | 208 | emitter.complete(); |
| 178 | 209 | return true; |
| ... | ... | @@ -181,6 +212,8 @@ public class AgentChatController { |
| 181 | 212 | String err = r.path("error").asText(""); |
| 182 | 213 | if (!err.isBlank()) { |
| 183 | 214 | send(emitter, "token", err); |
| 215 | + ledger.append(convId, "assistant", Map.of("text", err)); | |
| 216 | + appendMemoryTurn(convId, userInput, err); | |
| 184 | 217 | send(emitter, "done", ""); |
| 185 | 218 | emitter.complete(); |
| 186 | 219 | return true; |
| ... | ... | @@ -191,14 +224,58 @@ public class AgentChatController { |
| 191 | 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 | 249 | try { |
| 197 | 250 | ReActAgent agent = agentFactory.build(identity); |
| 251 | + AtomicInteger toolCalls = new AtomicInteger(); | |
| 252 | + AtomicBoolean proposed = new AtomicBoolean(false); | |
| 198 | 253 | TokenStream ts = agent.chat(convId, text); |
| 199 | 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 | 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 | 279 | send(emitter, "done", ""); |
| 203 | 280 | emitter.complete(); |
| 204 | 281 | }) |
| ... | ... | @@ -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 | 307 | /** 把意图门结果作为 grounding 附在用户消息后,稳住下游 agent 的选工具与实体理解。 */ |
| 219 | 308 | private String ground(String userInput, Intent it) { |
| 220 | 309 | StringBuilder g = new StringBuilder(userInput); |
| ... | ... | @@ -242,8 +331,8 @@ public class AgentChatController { |
| 242 | 331 | * 缺了就**确定性问一次**并停下——两头都不进失控循环。 |
| 243 | 332 | */ |
| 244 | 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 | 336 | log.info("write-slots(conv={}): entity={} record={} field={} newValue={}", |
| 248 | 337 | convId, w.entityType, w.record, w.field, w.newValue); |
| 249 | 338 | |
| ... | ... | @@ -256,11 +345,11 @@ public class AgentChatController { |
| 256 | 345 | if (isBlank(w.record)) need.add("要修改哪条记录(名称/单号)"); |
| 257 | 346 | if (isBlank(w.newValue)) need.add("改成什么新值"); |
| 258 | 347 | if (!need.isEmpty()) { |
| 259 | - clarifyWrite(emitter, "修改", w.record, need); | |
| 348 | + clarifyWrite(emitter, convId, userInput, "修改", w.record, need); | |
| 260 | 349 | return; |
| 261 | 350 | } |
| 262 | 351 | } else if (isBlank(w.record)) { |
| 263 | - clarifyWrite(emitter, actionVerb(action), "", | |
| 352 | + clarifyWrite(emitter, convId, userInput, actionVerb(action), "", | |
| 264 | 353 | java.util.List.of("要" + actionVerb(action) + "哪条记录(名称/单号)")); |
| 265 | 354 | return; |
| 266 | 355 | } |
| ... | ... | @@ -268,24 +357,32 @@ public class AgentChatController { |
| 268 | 357 | // 确定性调用 proposeWrite(不经 LLM 选工具,避免它反问/选错),直接渲染结果 |
| 269 | 358 | String result = agentFactory.proposeWriteTool(identity) |
| 270 | 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 | 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 | 366 | try { |
| 277 | 367 | JsonNode r = mapper.readTree(result); |
| 278 | 368 | String opId = r.path("opId").asText(null); |
| 279 | 369 | if (opId != null && !opId.isBlank()) { |
| 280 | 370 | ops.attachConversation(opId, convId); |
| 371 | + String summary = r.path("summary").asText(""); | |
| 281 | 372 | Map<String, Object> card = new LinkedHashMap<>(); |
| 282 | 373 | card.put("type", "write_proposal"); |
| 283 | 374 | card.put("opId", opId); |
| 284 | - card.put("summary", r.path("summary").asText("")); | |
| 375 | + card.put("summary", summary); | |
| 285 | 376 | sendEvent(emitter, card); |
| 286 | 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 | 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 | 387 | } catch (Exception e) { |
| 291 | 388 | send(emitter, "error", "服务异常:" + e.getMessage()); |
| ... | ... | @@ -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 | 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 | 434 | send(emitter, "done", ""); |
| 334 | 435 | emitter.complete(); |
| 335 | 436 | } |
| ... | ... | @@ -351,8 +452,8 @@ public class AgentChatController { |
| 351 | 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 | 457 | send(emitter, "reset", ""); |
| 357 | 458 | try { |
| 358 | 459 | String toolName = te.request() == null ? "" : te.request().name(); |
| ... | ... | @@ -364,18 +465,37 @@ public class AgentChatController { |
| 364 | 465 | String opId = r.path("opId").asText(null); |
| 365 | 466 | if (opId != null && !opId.isBlank()) { |
| 366 | 467 | ops.attachConversation(opId, convId); |
| 468 | + String summary = r.path("summary").asText(""); | |
| 367 | 469 | Map<String, Object> card = new LinkedHashMap<>(); |
| 368 | 470 | card.put("type", "write_proposal"); |
| 369 | 471 | card.put("opId", opId); |
| 370 | - card.put("summary", r.path("summary").asText("")); | |
| 472 | + card.put("summary", summary); | |
| 371 | 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 | 478 | } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) { |
| 374 | 479 | JsonNode r = mapper.readTree(te.result()); |
| 375 | 480 | String type = r.path("type").asText(""); |
| 376 | - if ("question".equals(type) || "form_collect".equals(type)) { | |
| 481 | + if ("question".equals(type)) { | |
| 377 | 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 | 500 | } catch (Exception e) { |
| 381 | 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 | 2 | |
| 3 | 3 | import com.fasterxml.jackson.databind.JsonNode; |
| 4 | 4 | import com.fasterxml.jackson.databind.ObjectMapper; |
| 5 | +import com.xly.config.RedisChatMemoryStore; | |
| 5 | 6 | import com.xly.service.AuditService; |
| 6 | 7 | import com.xly.service.ErpClient; |
| 8 | +import com.xly.service.LedgerService; | |
| 7 | 9 | import com.xly.service.OpService; |
| 10 | +import com.xly.service.StateService; | |
| 8 | 11 | import org.slf4j.Logger; |
| 9 | 12 | import org.slf4j.LoggerFactory; |
| 10 | 13 | import org.springframework.web.bind.annotation.GetMapping; |
| ... | ... | @@ -42,16 +45,49 @@ public class OpController { |
| 42 | 45 | private final ErpClient erp; |
| 43 | 46 | private final AuditService audit; |
| 44 | 47 | private final ObjectMapper mapper; |
| 48 | + private final LedgerService ledger; | |
| 49 | + private final StateService state; | |
| 50 | + private final RedisChatMemoryStore memoryStore; | |
| 45 | 51 | |
| 46 | 52 | /** true=确认后委托 ERP 侧暂存执行器(/ai/execStaging,§10 生产路径);false=xlyAi 直连 ERP 通用写接口执行。 */ |
| 47 | 53 | @org.springframework.beans.factory.annotation.Value("${erp.exec-staging.enabled:false}") |
| 48 | 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 | 58 | this.ops = ops; |
| 52 | 59 | this.erp = erp; |
| 53 | 60 | this.audit = audit; |
| 54 | 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 | 152 | if (code == 1) { |
| 117 | 153 | ops.setStatus(id, "executed", "操作成功"); |
| 118 | 154 | audit.log(uid, conv, "confirm", target, detail, true, "executed"); |
| 155 | + recordOutcome(conv, id, "executed", null, detail); | |
| 119 | 156 | return result("executed", "已执行:" + op.get("sDescription"), op); |
| 120 | 157 | } |
| 121 | 158 | String msg = r.path("msg").asText("执行失败"); |
| 122 | 159 | ops.setStatus(id, "failed", msg); |
| 123 | 160 | audit.log(uid, conv, "confirm", target, detail, false, msg); |
| 161 | + recordOutcome(conv, id, "failed", msg, detail); | |
| 124 | 162 | return result("failed", msg, op); |
| 125 | 163 | } catch (Exception e) { |
| 126 | 164 | log.warn("confirm op {} failed", id, e); |
| 127 | 165 | ops.setStatus(id, "failed", e.getMessage()); |
| 128 | 166 | audit.log(uid, conv, "confirm", target, detail, false, e.getMessage()); |
| 167 | + recordOutcome(conv, id, "failed", e.getMessage(), detail); | |
| 129 | 168 | return result("failed", "执行异常:" + e.getMessage(), op); |
| 130 | 169 | } |
| 131 | 170 | } |
| ... | ... | @@ -139,11 +178,13 @@ public class OpController { |
| 139 | 178 | String msg = r.path("msg").asText(""); |
| 140 | 179 | boolean ok = "executed".equals(st); |
| 141 | 180 | audit.log(uid, conv, "confirm", target, detail, ok, "execStaging:" + st + (msg.isEmpty() ? "" : (" " + msg))); |
| 181 | + recordOutcome(conv, id, ok ? "executed" : "failed", msg, detail); | |
| 142 | 182 | return result(ok ? "executed" : "failed", |
| 143 | 183 | ok ? ("已执行:" + op.get("sDescription")) : (msg.isEmpty() ? "执行失败" : msg), op); |
| 144 | 184 | } catch (Exception e) { |
| 145 | 185 | log.warn("execStaging confirm op {} failed", id, e); |
| 146 | 186 | audit.log(uid, conv, "confirm", target, detail, false, e.getMessage()); |
| 187 | + recordOutcome(conv, id, "failed", e.getMessage(), detail); | |
| 147 | 188 | return result("failed", "执行异常:" + e.getMessage(), op); |
| 148 | 189 | } |
| 149 | 190 | } |
| ... | ... | @@ -157,6 +198,7 @@ public class OpController { |
| 157 | 198 | audit.log(str(op.get("sUserId")), str(op.get("sConversationId")), "cancel", |
| 158 | 199 | str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId")), |
| 159 | 200 | str(op.get("sDescription")), true, "cancelled"); |
| 201 | + recordOutcome(str(op.get("sConversationId")), id, "cancelled", null, str(op.get("sDescription"))); | |
| 160 | 202 | } |
| 161 | 203 | return result("cancelled", "已取消", op); |
| 162 | 204 | } | ... | ... |