From 0b89179e5cfa69809b50b80bd68e0073b53db127 Mon Sep 17 00:00:00 2001 From: zichun <26684461+reporkey@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:15:13 +0800 Subject: [PATCH] feat(context): conversation ledger, code-written state slots, token-budget projection, anti-fabrication guards --- src/main/java/com/xly/agent/ProjectedChatMemory.java | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/config/AgentFactory.java | 9 +++------ src/main/java/com/xly/config/RedisChatMemoryStore.java | 14 ++++++++++++++ src/main/java/com/xly/service/ConversationService.java | 47 +++++++++++++++++++++++++++++++++++++++++++++-- src/main/java/com/xly/service/IntentService.java | 23 +++++++++++++++++++++-- src/main/java/com/xly/service/LedgerService.java | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/service/StateService.java | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main/java/com/xly/web/AgentChatController.java | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------- src/main/java/com/xly/web/OpController.java | 44 +++++++++++++++++++++++++++++++++++++++++++- 9 files changed, 720 insertions(+), 38 deletions(-) create mode 100644 src/main/java/com/xly/agent/ProjectedChatMemory.java create mode 100644 src/main/java/com/xly/service/LedgerService.java create mode 100644 src/main/java/com/xly/service/StateService.java diff --git a/src/main/java/com/xly/agent/ProjectedChatMemory.java b/src/main/java/com/xly/agent/ProjectedChatMemory.java new file mode 100644 index 0000000..cc6c8e6 --- /dev/null +++ b/src/main/java/com/xly/agent/ProjectedChatMemory.java @@ -0,0 +1,173 @@ +package com.xly.agent; + +import dev.langchain4j.agent.tool.ToolExecutionRequest; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.ToolExecutionResultMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.memory.ChatMemory; +import dev.langchain4j.store.memory.chat.ChatMemoryStore; + +import java.util.ArrayList; +import java.util.List; + +/** + * 带**投影**的对话记忆:存储层保留完整消息({@link ChatMemoryStore},硬上限按整轮裁剪), + * 读取层({@link #messages()})做 token 预算投影——替换按条数计窗的 MessageWindowChatMemory + * (工具消息占条数导致真实轮次只有 5-8 轮,且长短不均)。 + * + *

投影规则: + *

+ */ +public class ProjectedChatMemory implements ChatMemory { + + private static final int TOOL_DIGEST_LEN = 120; + + private final Object id; + private final ChatMemoryStore store; + private final int charBudget; + private final int hardCapMessages; + + public ProjectedChatMemory(Object id, ChatMemoryStore store, int charBudget, int hardCapMessages) { + this.id = id; + this.store = store; + this.charBudget = charBudget; + this.hardCapMessages = hardCapMessages; + } + + @Override + public Object id() { + return id; + } + + @Override + public void add(ChatMessage m) { + List full = new ArrayList<>(store.getMessages(id)); + if (m instanceof SystemMessage sm) { + if (!full.isEmpty() && full.get(0) instanceof SystemMessage cur) { + if (cur.text().equals(sm.text())) { + return; + } + full.set(0, sm); + } else { + full.add(0, sm); + } + } else { + full.add(m); + trimToCap(full); + } + store.updateMessages(id, full); + } + + @Override + public List messages() { + return project(new ArrayList<>(store.getMessages(id))); + } + + @Override + public void clear() { + store.deleteMessages(id); + } + + private List project(List full) { + if (full.isEmpty()) { + return full; + } + SystemMessage sys = full.get(0) instanceof SystemMessage s ? s : null; + List body = full.subList(sys == null ? 0 : 1, full.size()); + + int lastUser = 0; + for (int i = body.size() - 1; i >= 0; i--) { + if (body.get(i) instanceof UserMessage) { + lastUser = i; + break; + } + } + List tail = new ArrayList<>(body.subList(lastUser, body.size())); + + List head = new ArrayList<>(); + int used = 0; + int turnEnd = lastUser; + for (int i = lastUser - 1; i >= 0 && used < charBudget; i--) { + if (!(body.get(i) instanceof UserMessage)) { + continue; + } + List turn = new ArrayList<>(); + int size = 0; + for (int k = i; k < turnEnd; k++) { + ChatMessage c = collapse(body.get(k)); + turn.add(c); + size += approxLen(c); + } + if (used + size > charBudget && !head.isEmpty()) { + break; + } + head.addAll(0, turn); + used += size; + turnEnd = i; + } + + List out = new ArrayList<>(); + if (sys != null) { + out.add(sys); + } + out.addAll(head); + out.addAll(tail); + return out; + } + + /** 历史轮的工具结果压成一行摘要(当前轮不经过此路径,配对结构完整)。 */ + private static ChatMessage collapse(ChatMessage m) { + if (m instanceof ToolExecutionResultMessage t) { + String txt = t.text() == null ? "" : t.text().replace('\n', ' ').trim(); + if (txt.length() > TOOL_DIGEST_LEN) { + txt = txt.substring(0, TOOL_DIGEST_LEN) + "…"; + } + return ToolExecutionResultMessage.from(t.id(), t.toolName(), txt); + } + return m; + } + + private static int approxLen(ChatMessage m) { + if (m instanceof UserMessage u && u.hasSingleText()) { + return u.singleText().length(); + } + if (m instanceof AiMessage a) { + int n = a.text() == null ? 0 : a.text().length(); + if (a.hasToolExecutionRequests()) { + for (ToolExecutionRequest r : a.toolExecutionRequests()) { + n += (r.arguments() == null ? 0 : r.arguments().length()) + 20; + } + } + return n; + } + if (m instanceof ToolExecutionResultMessage t) { + return t.text() == null ? 0 : t.text().length(); + } + return 50; + } + + /** 存储硬上限:超限时从最旧的整轮开始删(system 保留)。 */ + private void trimToCap(List full) { + int start = !full.isEmpty() && full.get(0) instanceof SystemMessage ? 1 : 0; + while (full.size() > hardCapMessages) { + int next = -1; + for (int i = start + 1; i < full.size(); i++) { + if (full.get(i) instanceof UserMessage) { + next = i; + break; + } + } + if (next < 0) { + break; + } + full.subList(start, next).clear(); + } + } +} diff --git a/src/main/java/com/xly/config/AgentFactory.java b/src/main/java/com/xly/config/AgentFactory.java index 62bfbb1..1726b6a 100644 --- a/src/main/java/com/xly/config/AgentFactory.java +++ b/src/main/java/com/xly/config/AgentFactory.java @@ -12,7 +12,7 @@ import com.xly.tool.FormCollectTool; import com.xly.tool.InteractionTool; import com.xly.tool.KgQueryTool; import com.xly.tool.ProposeWriteTool; -import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import com.xly.agent.ProjectedChatMemory; import dev.langchain4j.model.chat.StreamingChatModel; import dev.langchain4j.service.AiServices; import org.springframework.beans.factory.annotation.Qualifier; @@ -77,11 +77,8 @@ public class AgentFactory { .streamingChatModel(streamingModel) .tools(tools) .maxSequentialToolsInvocations(8) // 循环护栏:防止 askUser/工具无限自我循环 - .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() - .id(memoryId) - .maxMessages(30) - .chatMemoryStore(memoryStore) - .build()) + // token 预算投影记忆:存储保完整、读取按预算收拢,旧工具结果压一行(见 ProjectedChatMemory) + .chatMemoryProvider(memoryId -> new ProjectedChatMemory(memoryId, memoryStore, 6000, 80)) .systemMessageProvider(memoryId -> systemPromptService.prompt()) .build(); } diff --git a/src/main/java/com/xly/config/RedisChatMemoryStore.java b/src/main/java/com/xly/config/RedisChatMemoryStore.java index 5cbea5e..50afe22 100644 --- a/src/main/java/com/xly/config/RedisChatMemoryStore.java +++ b/src/main/java/com/xly/config/RedisChatMemoryStore.java @@ -1,8 +1,10 @@ package com.xly.config; +import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.ChatMessageDeserializer; import dev.langchain4j.data.message.ChatMessageSerializer; +import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.store.memory.chat.ChatMemoryStore; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; @@ -47,4 +49,16 @@ public class RedisChatMemoryStore implements ChatMemoryStore { public void deleteMessages(Object memoryId) { redis.delete(PREFIX + memoryId); } + + /** 确定性路径(表单/澄清/提议/确认)不经过 LLM 记忆——用它把该轮补进存储,修补记忆空洞。 */ + public void appendTurn(Object memoryId, String userText, String aiText) { + List full = new ArrayList<>(getMessages(memoryId)); + if (userText != null && !userText.isBlank()) { + full.add(UserMessage.from(userText)); + } + if (aiText != null && !aiText.isBlank()) { + full.add(AiMessage.from(aiText)); + } + updateMessages(memoryId, full); + } } diff --git a/src/main/java/com/xly/service/ConversationService.java b/src/main/java/com/xly/service/ConversationService.java index 0e5bca5..9782007 100644 --- a/src/main/java/com/xly/service/ConversationService.java +++ b/src/main/java/com/xly/service/ConversationService.java @@ -27,11 +27,16 @@ public class ConversationService { private final StringRedisTemplate redis; private final RedisChatMemoryStore memoryStore; private final ObjectMapper mapper; + private final LedgerService ledger; + private final StateService state; - public ConversationService(StringRedisTemplate redis, RedisChatMemoryStore memoryStore, ObjectMapper mapper) { + public ConversationService(StringRedisTemplate redis, RedisChatMemoryStore memoryStore, + ObjectMapper mapper, LedgerService ledger, StateService state) { this.redis = redis; this.memoryStore = memoryStore; this.mapper = mapper; + this.ledger = ledger; + this.state = state; } /** 新建一个空会话,返回 convId。 */ @@ -87,10 +92,34 @@ public class ConversationService { public void delete(String userId, String convId) { redis.opsForHash().delete(CONVS_KEY + userId, convId); memoryStore.deleteMessages(convId); + ledger.delete(convId); + state.delete(convId); } - /** 会话历史,映射为 {role:user|ai, content}。跳过系统消息、工具调用中间消息、工具结果。 */ + /** + * 会话历史:优先从**会话账本**重放(含确定性路径的表单/澄清/提议/确认结果——消息记忆里没有这些); + * 无账本的旧会话退回消息记忆。映射为 {role:user|ai, content}。 + */ public List> history(String convId) { + List> events = ledger.events(convId); + if (!events.isEmpty()) { + List> out = new ArrayList<>(); + for (Map e : events) { + String type = String.valueOf(e.get("type")); + switch (type) { + case "user" -> out.add(Map.of("role", "user", "content", s(e.get("text")))); + case "assistant", "clarify" -> addAi(out, s(e.get("text"))); + case "question" -> addAi(out, s(e.get("question"))); + case "form" -> addAi(out, "【表单】新建" + s(e.get("entity")) + ":" + s(e.get("message"))); + case "proposal" -> addAi(out, "【待确认】" + s(e.get("summary"))); + case "confirm" -> addAi(out, ("executed".equals(s(e.get("status"))) ? "【已执行】" : "【执行失败】") + + s(e.get("description")) + blankOr(s(e.get("msg")))); + case "cancel" -> addAi(out, "【已取消】" + s(e.get("description"))); + default -> { } // tool 等内部事件不进历史 + } + } + return out; + } List> out = new ArrayList<>(); for (ChatMessage m : memoryStore.getMessages(convId)) { if (m instanceof UserMessage um && um.hasSingleText()) { @@ -103,6 +132,20 @@ public class ConversationService { return out; } + private static void addAi(List> out, String text) { + if (text != null && !text.isBlank()) { + out.add(Map.of("role", "ai", "content", text)); + } + } + + private static String s(Object o) { + return o == null ? "" : o.toString(); + } + + private static String blankOr(String msg) { + return msg == null || msg.isBlank() ? "" : ("(" + msg + ")"); + } + private String deriveTitle(String s) { if (s == null) { return "新会话"; diff --git a/src/main/java/com/xly/service/IntentService.java b/src/main/java/com/xly/service/IntentService.java index d593049..682c18a 100644 --- a/src/main/java/com/xly/service/IntentService.java +++ b/src/main/java/com/xly/service/IntentService.java @@ -47,11 +47,16 @@ public class IntentService { /** 意图门主入口。utterance 为空或模型失败时返回 其他。 */ public Intent classify(String utterance) { + return classify(utterance, null); + } + + /** 带会话状态摘要的分类:状态槽让「那张单子」这类跨轮指代可解。 */ + public Intent classify(String utterance, String stateDigest) { Intent out = new Intent(); if (utterance == null || utterance.isBlank()) { return out; } - JsonNode n = llm.completeJson(SYSTEM, utterance.trim(), schema()); + JsonNode n = llm.completeJson(SYSTEM, withState(utterance, stateDigest), schema()); if (n == null) { log.warn("intent classify fell back to 其他 (model unavailable)"); return out; @@ -88,11 +93,16 @@ public class IntentService { /** 抽取写操作槽位(修改/删除/审核用)。失败返回空槽位。 */ public Intent.WriteSlots extractWrite(String utterance) { + return extractWrite(utterance, null); + } + + /** 带会话状态摘要的写槽位抽取(「把那张报价单作废」的 record 可从状态里补齐)。 */ + public Intent.WriteSlots extractWrite(String utterance, String stateDigest) { Intent.WriteSlots w = new Intent.WriteSlots(); if (utterance == null || utterance.isBlank()) { return w; } - JsonNode n = llm.completeJson(WRITE_SYSTEM, utterance.trim(), writeSchema()); + JsonNode n = llm.completeJson(WRITE_SYSTEM, withState(utterance, stateDigest), writeSchema()); if (n == null) { return w; } @@ -116,6 +126,15 @@ public class IntentService { return schema; } + /** 状态摘要作为输入前缀(有状态才加,单句冷启动时输入形态不变)。 */ + private static String withState(String utterance, String stateDigest) { + String u = utterance.trim(); + if (stateDigest == null || stateDigest.isBlank()) { + return u; + } + return "【会话状态】" + stateDigest + "\n【这句话】" + u; + } + private static String normalizeIntent(String s) { if (s == null) return Intent.OTHER; s = s.trim(); diff --git a/src/main/java/com/xly/service/LedgerService.java b/src/main/java/com/xly/service/LedgerService.java new file mode 100644 index 0000000..1aaced9 --- /dev/null +++ b/src/main/java/com/xly/service/LedgerService.java @@ -0,0 +1,87 @@ +package com.xly.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 会话账本(append-only 事件流)—— 会话里发生过的**一切**按序落账,包括不经过 LLM 的确定性路径 + * (表单弹出/澄清/写提议/确认结果),修补「确定性路径不进对话记忆」的记忆空洞;前端历史从账本重放。 + * + *

键:Redis LIST {@code chat:ledger:{convId}},元素为事件 JSON {@code {t,type,...}},30 天 TTL。 + * 事件类型:{@code user}(text)/ {@code assistant}(text)/ {@code clarify}(text)/ + * {@code form}(entity,message)/ {@code question}(question,options)/ {@code tool}(name,digest)/ + * {@code proposal}(opId,summary)/ {@code confirm}(opId,status,msg)/ {@code cancel}(opId,description)。 + */ +@Service +public class LedgerService { + + private static final Logger log = LoggerFactory.getLogger(LedgerService.class); + private static final String PREFIX = "chat:ledger:"; + private static final Duration TTL = Duration.ofDays(30); + + private final StringRedisTemplate redis; + private final ObjectMapper mapper; + + public LedgerService(StringRedisTemplate redis, ObjectMapper mapper) { + this.redis = redis; + this.mapper = mapper; + } + + /** 追加一条事件(绝不抛异常——账本失败不能影响对话主流程)。 */ + public void append(String convId, String type, Map data) { + if (convId == null || convId.isBlank()) { + return; + } + try { + Map ev = new LinkedHashMap<>(); + ev.put("t", System.currentTimeMillis()); + ev.put("type", type); + if (data != null) { + ev.putAll(data); + } + String key = PREFIX + convId; + redis.opsForList().rightPush(key, mapper.writeValueAsString(ev)); + redis.expire(key, TTL); + } catch (Exception e) { + log.warn("ledger append failed (conv={}, type={}): {}", convId, type, e.getMessage()); + } + } + + /** 全量事件(按发生顺序),供前端历史重放。 */ + public List> events(String convId) { + List> out = new ArrayList<>(); + try { + List raw = redis.opsForList().range(PREFIX + convId, 0, -1); + if (raw == null) { + return out; + } + for (String s : raw) { + try { + @SuppressWarnings("unchecked") + Map m = mapper.readValue(s, Map.class); + out.add(m); + } catch (Exception ignore) { + } + } + } catch (Exception e) { + log.warn("ledger read failed (conv={}): {}", convId, e.getMessage()); + } + return out; + } + + public void delete(String convId) { + try { + redis.delete(PREFIX + convId); + } catch (Exception ignore) { + } + } +} diff --git a/src/main/java/com/xly/service/StateService.java b/src/main/java/com/xly/service/StateService.java new file mode 100644 index 0000000..58846ba --- /dev/null +++ b/src/main/java/com/xly/service/StateService.java @@ -0,0 +1,187 @@ +package com.xly.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.xly.agent.Intent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.List; + +/** + * 会话状态槽 —— 由**代码**(而非模型总结)维护的少量结构化状态:上轮意图 / 最近实体 / 在办单据。 + * 注入两处:意图门的输入前缀(让「那张单子」这类指代可解),agent 用户消息尾部(稳住多轮上下文)。 + * + *

键:Redis HASH {@code chat:state:{convId}}(fields: intent/danju/entities/doc),30 天 TTL。 + */ +@Service +public class StateService { + + private static final Logger log = LoggerFactory.getLogger(StateService.class); + private static final String PREFIX = "chat:state:"; + private static final Duration TTL = Duration.ofDays(30); + private static final int MAX_ENTITIES = 8; + + private final StringRedisTemplate redis; + private final ObjectMapper mapper; + + public StateService(StringRedisTemplate redis, ObjectMapper mapper) { + this.redis = redis; + this.mapper = mapper; + } + + /** 记录本轮意图门结果(上轮意图 + 单据类型)。 */ + public void recordIntent(String convId, String intent, String danju) { + try { + String key = PREFIX + convId; + redis.opsForHash().put(key, "intent", intent == null ? "" : intent); + redis.opsForHash().put(key, "danju", danju == null ? "" : danju); + redis.expire(key, TTL); + } catch (Exception e) { + log.warn("state recordIntent failed (conv={}): {}", convId, e.getMessage()); + } + } + + /** 合并本轮识别到的实体(最新在前、按 值+角色 去重、封顶 {@value #MAX_ENTITIES} 个)。 */ + public void mergeEntities(String convId, List entities) { + if (entities == null || entities.isEmpty()) { + return; + } + try { + String key = PREFIX + convId; + ArrayNode merged = mapper.createArrayNode(); + for (Intent.Entity e : entities) { + if (e == null || e.value == null || e.value.isBlank()) continue; + ObjectNode n = merged.addObject(); + n.put("value", e.value.trim()); + n.put("role", e.role == null ? "未知" : e.role); + } + Object old = redis.opsForHash().get(key, "entities"); + if (old != null) { + JsonNode arr = mapper.readTree(old.toString()); + for (JsonNode n : arr) { + if (merged.size() >= MAX_ENTITIES) break; + boolean dup = false; + for (JsonNode m : merged) { + if (m.path("value").asText().equals(n.path("value").asText()) + && m.path("role").asText().equals(n.path("role").asText())) { + dup = true; + break; + } + } + if (!dup) merged.add(n); + } + } + redis.opsForHash().put(key, "entities", mapper.writeValueAsString(merged)); + redis.expire(key, TTL); + } catch (Exception e) { + log.warn("state mergeEntities failed (conv={}): {}", convId, e.getMessage()); + } + } + + /** 设置在办单据(entity=单据/实体类型,record=记录名/单号,stage=collecting|proposed|executed|failed|cancelled)。 */ + public void setActiveDoc(String convId, String entity, String record, String opId, String stage) { + try { + String key = PREFIX + convId; + ObjectNode doc = mapper.createObjectNode(); + doc.put("entity", entity == null ? "" : entity); + doc.put("record", record == null ? "" : record); + doc.put("opId", opId == null ? "" : opId); + doc.put("stage", stage == null ? "" : stage); + redis.opsForHash().put(key, "doc", mapper.writeValueAsString(doc)); + redis.expire(key, TTL); + } catch (Exception e) { + log.warn("state setActiveDoc failed (conv={}): {}", convId, e.getMessage()); + } + } + + /** 确认/取消后推进在办单据阶段(仅当 opId 匹配当前在办单据)。 */ + public void updateDocStage(String convId, String opId, String stage) { + if (convId == null || convId.isBlank() || opId == null || opId.isBlank()) { + return; + } + try { + String key = PREFIX + convId; + Object old = redis.opsForHash().get(key, "doc"); + if (old == null) { + return; + } + ObjectNode doc = (ObjectNode) mapper.readTree(old.toString()); + if (!opId.equals(doc.path("opId").asText(""))) { + return; + } + doc.put("stage", stage); + redis.opsForHash().put(key, "doc", mapper.writeValueAsString(doc)); + } catch (Exception e) { + log.warn("state updateDocStage failed (conv={}): {}", convId, e.getMessage()); + } + } + + /** 状态摘要(一行中文),空状态返回 ""。喂意图门 + 附在 agent 用户消息尾部。 */ + public String digest(String convId) { + try { + String key = PREFIX + convId; + Object intent = redis.opsForHash().get(key, "intent"); + Object danju = redis.opsForHash().get(key, "danju"); + Object doc = redis.opsForHash().get(key, "doc"); + Object entities = redis.opsForHash().get(key, "entities"); + StringBuilder sb = new StringBuilder(); + if (intent != null && !intent.toString().isBlank()) { + sb.append("上轮意图=").append(intent); + if (danju != null && !danju.toString().isBlank()) { + sb.append("(").append(danju).append(")"); + } + } + if (doc != null) { + JsonNode d = mapper.readTree(doc.toString()); + String ent = d.path("entity").asText(""); + String rec = d.path("record").asText(""); + String stage = d.path("stage").asText(""); + if (!ent.isBlank() || !rec.isBlank()) { + if (sb.length() > 0) sb.append(";"); + sb.append("在办单据=").append(ent); + if (!rec.isBlank()) sb.append("【").append(rec).append("】"); + if (!stage.isBlank()) sb.append("(").append(stageZh(stage)).append(")"); + } + } + if (entities != null) { + JsonNode arr = mapper.readTree(entities.toString()); + StringBuilder es = new StringBuilder(); + for (JsonNode n : arr) { + if (es.length() > 0) es.append("、"); + es.append(n.path("role").asText("未知")).append("=").append(n.path("value").asText("")); + } + if (es.length() > 0) { + if (sb.length() > 0) sb.append(";"); + sb.append("最近实体=").append(es); + } + } + return sb.toString(); + } catch (Exception e) { + return ""; + } + } + + private static String stageZh(String stage) { + switch (stage) { + case "collecting": return "填表中"; + case "proposed": return "待确认"; + case "executed": return "已执行"; + case "failed": return "执行失败"; + case "cancelled": return "已取消"; + default: return stage; + } + } + + public void delete(String convId) { + try { + redis.delete(PREFIX + convId); + } catch (Exception ignore) { + } + } +} diff --git a/src/main/java/com/xly/web/AgentChatController.java b/src/main/java/com/xly/web/AgentChatController.java index 908b156..07b1617 100644 --- a/src/main/java/com/xly/web/AgentChatController.java +++ b/src/main/java/com/xly/web/AgentChatController.java @@ -6,12 +6,15 @@ import com.xly.agent.AgentIdentity; import com.xly.agent.Intent; import com.xly.agent.ReActAgent; import com.xly.config.AgentFactory; +import com.xly.config.RedisChatMemoryStore; import com.xly.service.AuthzService; import com.xly.service.ConversationService; import com.xly.service.FormResolverService; import com.xly.service.IntentService; +import com.xly.service.LedgerService; import com.xly.service.OpService; import com.xly.service.SlotFillService; +import com.xly.service.StateService; import com.xly.tool.FormCollectTool; import dev.langchain4j.service.TokenStream; import dev.langchain4j.service.tool.ToolExecution; @@ -31,6 +34,9 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Pattern; /** * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 @@ -56,6 +62,8 @@ public class AgentChatController { private static final Set WRITE_TOOLS = Set.of("proposeWrite"); /** 前端 collectForm 表单提交后拼出的消息带此标记 → 本轮直接走「写」执行 proposeWrite(action=create)。 */ private static final String FORM_SUBMIT_MARK = "proposeWrite(action=create)"; + /** 反编造护栏:agent 声称「已生成/已完成」写操作的说法(无 proposeWrite 提议时要纠正)。 */ + private static final Pattern WRITE_CLAIM = Pattern.compile("已(为您?|经)?(生成|提交|完成|写入|新增|修改|作废|审核)"); private final AgentFactory agentFactory; private final AuthzService authz; @@ -65,12 +73,16 @@ public class AgentChatController { private final IntentService intentService; private final SlotFillService slotFill; private final FormResolverService resolver; + private final LedgerService ledger; + private final StateService state; + private final RedisChatMemoryStore memoryStore; private final ExecutorService exec = Executors.newCachedThreadPool(); public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper, ConversationService conversations, OpService ops, IntentService intentService, SlotFillService slotFill, - FormResolverService resolver) { + FormResolverService resolver, LedgerService ledger, + StateService state, RedisChatMemoryStore memoryStore) { this.agentFactory = agentFactory; this.authz = authz; this.mapper = mapper; @@ -79,6 +91,9 @@ public class AgentChatController { this.intentService = intentService; this.slotFill = slotFill; this.resolver = resolver; + this.ledger = ledger; + this.state = state; + this.memoryStore = memoryStore; } public static class ChatReq { @@ -101,6 +116,7 @@ public class AgentChatController { : ((req.userid == null ? "anon" : req.userid) + ":default"); conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput); + ledger.append(convId, "user", Map.of("text", userInput)); final AgentIdentity identity = resolveIdentity(req); exec.submit(() -> { @@ -119,11 +135,14 @@ public class AgentChatController { private void route(SseEmitter emitter, String convId, AgentIdentity identity, String userInput) { // 0) 表单提交 → 直接执行 proposeWrite(action=create),不再重新分类。 if (userInput.contains(FORM_SUBMIT_MARK)) { - runAgent(emitter, convId, identity, userInput); + runAgent(emitter, convId, identity, userInput, false); return; } - // 1) 意图门(失败时返回 其他,走兜底)。 - Intent it = intentService.classify(userInput); + // 1) 意图门(带上一轮留下的状态槽;失败时返回 其他,走兜底)。 + String digest = state.digest(convId); + Intent it = intentService.classify(userInput, digest); + state.recordIntent(convId, it.intent, it.danju); + state.mergeEntities(convId, it.entities); log.info("intent(conv={}): {} / {} / entities={} / missing={}", convId, it.intent, it.danju, it.describeEntities(), it.missing); @@ -133,20 +152,28 @@ public class AgentChatController { return; } // 无法确定性建表单 → 交给 agent 处理(可能需要它先问清单据类型)。 - runAgent(emitter, convId, identity, ground(userInput, it)); + runAgent(emitter, convId, identity, withState(ground(userInput, it), digest), false); return; case Intent.OPERATE: - handleWrite(emitter, convId, identity, userInput, it); + handleWrite(emitter, convId, identity, userInput, it, digest); return; case Intent.QUERY: - runAgent(emitter, convId, identity, ground(userInput, it)); + runAgent(emitter, convId, identity, withState(ground(userInput, it), digest), true); return; default: // 其他/分类失败:原文交给 agent,尽量不丢能力。 - runAgent(emitter, convId, identity, userInput); + runAgent(emitter, convId, identity, withState(userInput, digest), false); } } + /** 状态槽注入在用户消息尾部(空状态时原样返回,保持 KV 前缀稳定)。 */ + private static String withState(String text, String digest) { + if (digest == null || digest.isBlank()) { + return text; + } + return text + "\n\n(会话状态,仅供参考:" + digest + ")"; + } + /** * 确定性「新增」:解析目标表单 → 受约束槽位填充 → 弹 collectForm 表单。全程不经 LLM 选工具, * 因此「纸盒」这类产品名不可能被塞进客户字段。返回 false 表示无法处理(交回 route 兜底)。 @@ -172,7 +199,11 @@ public class AgentChatController { JsonNode r = mapper.readTree(payload); if ("form_collect".equals(r.path("type").asText(""))) { sendEvent(emitter, mapper.convertValue(r, Map.class)); - send(emitter, "token", "请在下方表单里填写(客户/产品从下拉里选真实数据),填完点【提交】。"); + String hint = "请在下方表单里填写(客户/产品从下拉里选真实数据),填完点【提交】。"; + send(emitter, "token", hint); + ledger.append(convId, "form", Map.of("entity", entity, "message", hint)); + state.setActiveDoc(convId, entity, "", "", "collecting"); + appendMemoryTurn(convId, userInput, "已为「" + entity + "」弹出新建表单,等待用户填写提交。"); send(emitter, "done", ""); emitter.complete(); return true; @@ -181,6 +212,8 @@ public class AgentChatController { String err = r.path("error").asText(""); if (!err.isBlank()) { send(emitter, "token", err); + ledger.append(convId, "assistant", Map.of("text", err)); + appendMemoryTurn(convId, userInput, err); send(emitter, "done", ""); emitter.complete(); return true; @@ -191,14 +224,58 @@ public class AgentChatController { return false; } - /** 运行 ReAct agent,把流式回调转成 SSE。 */ - private void runAgent(SseEmitter emitter, String convId, AgentIdentity identity, String text) { + /** 确定性路径不经过 LLM 记忆——把这轮 用户话+系统答复 补进对话记忆,修补记忆空洞。 */ + private void appendMemoryTurn(String convId, String userText, String aiText) { + try { + memoryStore.appendTurn(convId, userText, aiText); + } catch (Exception e) { + log.warn("append memory turn failed (conv={}): {}", convId, e.getMessage()); + } + } + + /** 运行 ReAct agent,把流式回调转成 SSE。queryGuard=true 时启用查询反编造护栏。 */ + private void runAgent(SseEmitter emitter, String convId, AgentIdentity identity, + String text, boolean queryGuard) { + runAgentAttempt(emitter, convId, identity, text, queryGuard, true); + } + + /** + * 反编造护栏(代码层——实测 Ollama 对 tool_choice=required 不硬执行): + * 查询轮**零工具调用**却答出数字 → 重试一次强制先查数,仍复发则标注「未经核实」; + * 非查询轮声称「已生成/已完成」但没真正 proposeWrite → 附纠正提示(无提议卡片即未生效)。 + */ + private void runAgentAttempt(SseEmitter emitter, String convId, AgentIdentity identity, + String text, boolean queryGuard, boolean allowRetry) { try { ReActAgent agent = agentFactory.build(identity); + AtomicInteger toolCalls = new AtomicInteger(); + AtomicBoolean proposed = new AtomicBoolean(false); TokenStream ts = agent.chat(convId, text); ts.onPartialResponse(token -> send(emitter, "token", token)) - .onToolExecuted(te -> handleToolExecuted(emitter, convId, te)) + .onToolExecuted(te -> { + toolCalls.incrementAndGet(); + handleToolExecuted(emitter, convId, te, proposed); + }) .onCompleteResponse(resp -> { + String answer = resp == null || resp.aiMessage() == null || resp.aiMessage().text() == null + ? "" : resp.aiMessage().text(); + if (queryGuard && toolCalls.get() == 0 && hasDigits(answer)) { + if (allowRetry) { + log.warn("anti-fab retry (conv={}): zero tools + digits", convId); + send(emitter, "reset", ""); + runAgentAttempt(emitter, convId, identity, + "你上一条回答没有调用任何工具、数字疑似编造。请先用工具查询真实数据,再重新回答这个问题:" + text, + true, false); + return; + } + send(emitter, "token", "\n\n⚠️ 注意:以上数字未能经系统数据核实,仅供参考。"); + } + if (!queryGuard && !proposed.get() && WRITE_CLAIM.matcher(answer).find()) { + send(emitter, "token", "\n\n⚠️ 系统提示:本条回复没有真正生成待确认操作(没有出现提议卡片即未生效),请重新描述一次您要做的操作。"); + } + if (!answer.isBlank()) { + ledger.append(convId, "assistant", Map.of("text", answer)); + } send(emitter, "done", ""); emitter.complete(); }) @@ -215,6 +292,18 @@ public class AgentChatController { } } + private static boolean hasDigits(String s) { + if (s == null) { + return false; + } + for (int i = 0; i < s.length(); i++) { + if (Character.isDigit(s.charAt(i))) { + return true; + } + } + return false; + } + /** 把意图门结果作为 grounding 附在用户消息后,稳住下游 agent 的选工具与实体理解。 */ private String ground(String userInput, Intent it) { StringBuilder g = new StringBuilder(userInput); @@ -242,8 +331,8 @@ public class AgentChatController { * 缺了就**确定性问一次**并停下——两头都不进失控循环。 */ private void handleWrite(SseEmitter emitter, String convId, AgentIdentity identity, - String userInput, Intent it) { - Intent.WriteSlots w = intentService.extractWrite(userInput); + String userInput, Intent it, String digest) { + Intent.WriteSlots w = intentService.extractWrite(userInput, digest); log.info("write-slots(conv={}): entity={} record={} field={} newValue={}", convId, w.entityType, w.record, w.field, w.newValue); @@ -256,11 +345,11 @@ public class AgentChatController { if (isBlank(w.record)) need.add("要修改哪条记录(名称/单号)"); if (isBlank(w.newValue)) need.add("改成什么新值"); if (!need.isEmpty()) { - clarifyWrite(emitter, "修改", w.record, need); + clarifyWrite(emitter, convId, userInput, "修改", w.record, need); return; } } else if (isBlank(w.record)) { - clarifyWrite(emitter, actionVerb(action), "", + clarifyWrite(emitter, convId, userInput, actionVerb(action), "", java.util.List.of("要" + actionVerb(action) + "哪条记录(名称/单号)")); return; } @@ -268,24 +357,32 @@ public class AgentChatController { // 确定性调用 proposeWrite(不经 LLM 选工具,避免它反问/选错),直接渲染结果 String result = agentFactory.proposeWriteTool(identity) .proposeWrite(action, ent, w.record, w.field, w.newValue, null); - emitWriteResult(emitter, convId, result); + emitWriteResult(emitter, convId, userInput, ent, w.record, result); } /** 把 proposeWrite 的返回渲染成 SSE:有 opId → 写提议卡;否则 → 文字反馈(定位失败/多条匹配等)。 */ - private void emitWriteResult(SseEmitter emitter, String convId, String result) { + private void emitWriteResult(SseEmitter emitter, String convId, String userInput, + String ent, String record, String result) { try { JsonNode r = mapper.readTree(result); String opId = r.path("opId").asText(null); if (opId != null && !opId.isBlank()) { ops.attachConversation(opId, convId); + String summary = r.path("summary").asText(""); Map card = new LinkedHashMap<>(); card.put("type", "write_proposal"); card.put("opId", opId); - card.put("summary", r.path("summary").asText("")); + card.put("summary", summary); sendEvent(emitter, card); send(emitter, "token", r.path("message").asText("已生成待确认操作,请点【确认】。")); + ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary)); + state.setActiveDoc(convId, ent, record, opId, "proposed"); + appendMemoryTurn(convId, userInput, "已生成待确认提议:" + summary + "(等待用户点确认/取消)"); } else { - send(emitter, "token", r.path("error").asText("无法完成该操作。")); + String err = r.path("error").asText("无法完成该操作。"); + send(emitter, "token", err); + ledger.append(convId, "assistant", Map.of("text", err)); + appendMemoryTurn(convId, userInput, err); } } catch (Exception e) { send(emitter, "error", "服务异常:" + e.getMessage()); @@ -326,10 +423,14 @@ public class AgentChatController { } } - private void clarifyWrite(SseEmitter emitter, String verb, String record, java.util.List need) { + private void clarifyWrite(SseEmitter emitter, String convId, String userInput, + String verb, String record, java.util.List need) { String who = isBlank(record) ? "" : ("(记录:" + record + ")"); - send(emitter, "token", "要" + verb + who + ",我还需要您补充:" + String.join("、", need) - + "。请一起告诉我,我再为你生成待确认的操作。"); + String text = "要" + verb + who + ",我还需要您补充:" + String.join("、", need) + + "。请一起告诉我,我再为你生成待确认的操作。"; + send(emitter, "token", text); + ledger.append(convId, "clarify", Map.of("text", text)); + appendMemoryTurn(convId, userInput, text); send(emitter, "done", ""); emitter.complete(); } @@ -351,8 +452,8 @@ public class AgentChatController { return authz.devIdentity(); } - /** 工具执行回调:清掉工具前旁白(reset);再按工具类型推对应卡片/控件事件。 */ - private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te) { + /** 工具执行回调:清掉工具前旁白(reset);按工具类型推对应卡片/控件事件;同步落账本/状态槽。 */ + private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te, AtomicBoolean proposed) { send(emitter, "reset", ""); try { String toolName = te.request() == null ? "" : te.request().name(); @@ -364,18 +465,37 @@ public class AgentChatController { String opId = r.path("opId").asText(null); if (opId != null && !opId.isBlank()) { ops.attachConversation(opId, convId); + String summary = r.path("summary").asText(""); Map card = new LinkedHashMap<>(); card.put("type", "write_proposal"); card.put("opId", opId); - card.put("summary", r.path("summary").asText("")); + card.put("summary", summary); sendEvent(emitter, card); + proposed.set(true); + ledger.append(convId, "proposal", Map.of("opId", opId, "summary", summary)); + state.setActiveDoc(convId, "", summary, opId, "proposed"); } } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) { JsonNode r = mapper.readTree(te.result()); String type = r.path("type").asText(""); - if ("question".equals(type) || "form_collect".equals(type)) { + if ("question".equals(type)) { sendEvent(emitter, mapper.convertValue(r, Map.class)); + ledger.append(convId, "question", Map.of( + "question", r.path("question").asText(""), + "options", mapper.convertValue(r.path("options"), List.class))); + } else if ("form_collect".equals(type)) { + sendEvent(emitter, mapper.convertValue(r, Map.class)); + String entity = r.path("entity").asText(""); + ledger.append(convId, "form", Map.of("entity", entity, + "message", r.path("message").asText(""))); + state.setActiveDoc(convId, entity, "", "", "collecting"); + } + } else { + String digest = te.result().replace('\n', ' ').trim(); + if (digest.length() > 100) { + digest = digest.substring(0, 100) + "…"; } + ledger.append(convId, "tool", Map.of("name", toolName, "digest", digest)); } } catch (Exception e) { log.warn("handle tool result failed ({})", te.request() == null ? "?" : te.request().name(), e); diff --git a/src/main/java/com/xly/web/OpController.java b/src/main/java/com/xly/web/OpController.java index 584339a..073f8f8 100644 --- a/src/main/java/com/xly/web/OpController.java +++ b/src/main/java/com/xly/web/OpController.java @@ -2,9 +2,12 @@ package com.xly.web; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.config.RedisChatMemoryStore; import com.xly.service.AuditService; import com.xly.service.ErpClient; +import com.xly.service.LedgerService; import com.xly.service.OpService; +import com.xly.service.StateService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.GetMapping; @@ -42,16 +45,49 @@ public class OpController { private final ErpClient erp; private final AuditService audit; private final ObjectMapper mapper; + private final LedgerService ledger; + private final StateService state; + private final RedisChatMemoryStore memoryStore; /** true=确认后委托 ERP 侧暂存执行器(/ai/execStaging,§10 生产路径);false=xlyAi 直连 ERP 通用写接口执行。 */ @org.springframework.beans.factory.annotation.Value("${erp.exec-staging.enabled:false}") private boolean execStagingEnabled; - public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper) { + public OpController(OpService ops, ErpClient erp, AuditService audit, ObjectMapper mapper, + LedgerService ledger, StateService state, RedisChatMemoryStore memoryStore) { this.ops = ops; this.erp = erp; this.audit = audit; this.mapper = mapper; + this.ledger = ledger; + this.state = state; + this.memoryStore = memoryStore; + } + + /** 确认/取消的结果落账本+状态槽+对话记忆(记忆空洞修补:LLM 下轮就知道这单已执行/失败/取消)。 */ + private void recordOutcome(String conv, String opId, String status, String msg, String description) { + if (conv == null || conv.isBlank() || "null".equals(conv)) { + return; + } + try { + ledger.append(conv, "cancelled".equals(status) ? "cancel" : "confirm", Map.of( + "opId", opId == null ? "" : opId, + "status", status, + "msg", msg == null ? "" : msg, + "description", description == null ? "" : description)); + state.updateDocStage(conv, opId, status); + String note; + if ("executed".equals(status)) { + note = "(系统)用户已确认,操作执行成功:" + description; + } else if ("cancelled".equals(status)) { + note = "(系统)用户取消了该操作:" + description; + } else { + note = "(系统)用户确认后执行失败:" + description + (msg == null || msg.isBlank() ? "" : (",原因:" + msg)); + } + memoryStore.appendTurn(conv, null, note); + } catch (Exception e) { + log.warn("record op outcome failed (conv={}, op={}): {}", conv, opId, e.getMessage()); + } } /** 会话里最近一条待确认操作(前端每轮结束后轮询,用于渲染确认卡片)。 */ @@ -116,16 +152,19 @@ public class OpController { if (code == 1) { ops.setStatus(id, "executed", "操作成功"); audit.log(uid, conv, "confirm", target, detail, true, "executed"); + recordOutcome(conv, id, "executed", null, detail); return result("executed", "已执行:" + op.get("sDescription"), op); } String msg = r.path("msg").asText("执行失败"); ops.setStatus(id, "failed", msg); audit.log(uid, conv, "confirm", target, detail, false, msg); + recordOutcome(conv, id, "failed", msg, detail); return result("failed", msg, op); } catch (Exception e) { log.warn("confirm op {} failed", id, e); ops.setStatus(id, "failed", e.getMessage()); audit.log(uid, conv, "confirm", target, detail, false, e.getMessage()); + recordOutcome(conv, id, "failed", e.getMessage(), detail); return result("failed", "执行异常:" + e.getMessage(), op); } } @@ -139,11 +178,13 @@ public class OpController { String msg = r.path("msg").asText(""); boolean ok = "executed".equals(st); audit.log(uid, conv, "confirm", target, detail, ok, "execStaging:" + st + (msg.isEmpty() ? "" : (" " + msg))); + recordOutcome(conv, id, ok ? "executed" : "failed", msg, detail); return result(ok ? "executed" : "failed", ok ? ("已执行:" + op.get("sDescription")) : (msg.isEmpty() ? "执行失败" : msg), op); } catch (Exception e) { log.warn("execStaging confirm op {} failed", id, e); audit.log(uid, conv, "confirm", target, detail, false, e.getMessage()); + recordOutcome(conv, id, "failed", e.getMessage(), detail); return result("failed", "执行异常:" + e.getMessage(), op); } } @@ -157,6 +198,7 @@ public class OpController { audit.log(str(op.get("sUserId")), str(op.get("sConversationId")), "cancel", str(op.get("sTargetTable")) + "#" + str(op.get("sTargetBillId")), str(op.get("sDescription")), true, "cancelled"); + recordOutcome(str(op.get("sConversationId")), id, "cancelled", null, str(op.get("sDescription"))); } return result("cancelled", "已取消", op); } -- libgit2 0.22.2