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 轮,且长短不均)。 + * + *
投影规则: + *
键: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); }
键: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); }