Commit 0b89179e5cfa69809b50b80bd68e0073b53db127

Authored by zichun
1 parent 7d3be390

feat(context): conversation ledger, code-written state slots, token-budget proje…

…ction, anti-fabrication guards

- LedgerService: append-only chat:ledger:{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
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 }
... ...