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