package com.xly.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.ChatMessageDeserializer; import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.ToolExecutionResultMessage; import dev.langchain4j.data.message.UserMessage; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 事件日志的**唯一**投影/渲染器 —— 前端历史与 LLM 上下文同源于 {@link LedgerService} 的事件流, * 文案在这一处生成,不再散落在各控制器里。 * *

LLM 上下文四段式({@link #project}): *

    *
  1. 稳定 system prompt(KV 前缀稳定);
  2. *
  3. 进行中流程卡(激活 skill 全文 + 在办单据状态)——钉在 system 尾部,不参与截断, * 提议 executed/cancelled 或新 skill 激活时摘下;
  4. *
  5. 往事摘要区:预算外旧轮 → 确定性一行摘要(零模型调用);
  6. *
  7. 近期原文区:约 charBudget 字符按**整轮**纳入;旧轮工具结果压 {@value #TOOL_DIGEST_LEN} 字; * 当前轮(最后一个用户事件起)原样载荷精确重建,工具调用/结果配对不可破坏。
  8. *
*/ @Service public class EventProjectionService { public static final int TOOL_DIGEST_LEN = 120; private static final int DIGEST_TURNS_MAX = 40; private static final int DIGEST_LINE_LEN = 90; private final ObjectMapper mapper; public EventProjectionService(ObjectMapper mapper) { this.mapper = mapper; } // ---------------------------------------------------------------- LLM 投影 /** 四段式 LLM 上下文。systemText 为空时不出 system 消息(如测试)。 */ public List project(String systemText, List> events, int charBudget) { List>> turns = groupTurns(events); int current = turns.size() - 1; // ④ 近期原文区:从最新往回按整轮纳入;当前轮必进且不计预算 int firstVerbatim = current; int used = 0; for (int i = current - 1; i >= 0; i--) { int size = 0; for (Map ev : turns.get(i)) { size += approxLen(ev); } if (used + size > charBudget) { break; } used += size; firstVerbatim = i; } List out = new ArrayList<>(); String card = activeCard(events); if (systemText != null && !systemText.isBlank()) { out.add(SystemMessage.from(card == null ? systemText : systemText + "\n\n【进行中的流程】\n" + card)); } // ③ 往事摘要区 if (firstVerbatim > 0) { StringBuilder sb = new StringBuilder("【更早对话摘要(自动截断,仅供参考)】"); int from = Math.max(0, firstVerbatim - DIGEST_TURNS_MAX); if (from > 0) { sb.append("\n(更早 ").append(from).append(" 轮已省略)"); } for (int i = from; i < firstVerbatim; i++) { String line = digestLine(turns.get(i)); if (!line.isBlank()) { sb.append("\n- ").append(line); } } out.add(UserMessage.from(sb.toString())); } for (int i = firstVerbatim; i < turns.size(); i++) { appendTurnMessages(out, turns.get(i), i == current); } return out; } /** 一轮 → LLM 消息。当前轮工具结果全文保真;旧轮压一行摘要。 */ private void appendTurnMessages(List out, List> turn, boolean isCurrent) { for (Map ev : turn) { String type = str(ev.get("type")); switch (type) { case "user" -> out.add(UserMessage.from(str(ev.get("text")))); case "form_submit" -> out.add(UserMessage.from(renderFormSubmit(ev))); case "ai", "assistant", "clarify" -> addAi(out, str(ev.get("text"))); case "tool_call" -> { ChatMessage m = fromPayload(str(ev.get("payload"))); if (m != null) { out.add(m); } } case "tool_result" -> { String text = isCurrent ? str(ev.get("text")) : str(ev.get("digest")); out.add(ToolExecutionResultMessage.from( str(ev.get("tcId")), str(ev.get("name")), text)); } case "question" -> addAi(out, str(ev.get("question"))); case "form" -> addAi(out, "已为「" + str(ev.get("entity")) + "」弹出新建表单,等待用户填写提交。"); case "proposal" -> addAi(out, "已生成待确认提议:" + str(ev.get("summary")) + "(等待用户点确认/取消,尚未执行)"); case "confirm", "cancel" -> addAi(out, renderOutcome(ev)); default -> { } // tool(旧版摘要)/skill_active/skill_done 不进正文(skill 由流程卡承载) } } } // ---------------------------------------------------------------- 前端历史投影 /** 前端历史(API 形状不变:{role:user|ai, content})。internal 用户事件(护栏重试话术)不显示。 */ public List> historyView(List> events) { List> out = new ArrayList<>(); for (Map ev : events) { String type = str(ev.get("type")); switch (type) { case "user" -> { if (!Boolean.TRUE.equals(ev.get("internal"))) { out.add(Map.of("role", "user", "content", str(ev.get("text")))); } } case "form_submit" -> out.add(Map.of("role", "user", "content", renderFormSubmit(ev))); case "ai", "assistant", "clarify" -> addHist(out, str(ev.get("text"))); case "question" -> addHist(out, str(ev.get("question"))); case "form" -> addHist(out, "【表单】新建" + str(ev.get("entity")) + ":" + str(ev.get("message"))); case "proposal" -> addHist(out, "【待确认】" + str(ev.get("summary"))); case "confirm" -> addHist(out, ("executed".equals(str(ev.get("status"))) ? "【已执行】" : "【执行失败】") + str(ev.get("description")) + parenOr(str(ev.get("msg")))); case "cancel" -> addHist(out, "【已取消】" + str(ev.get("description"))); case "tool_result" -> addHist(out, historyFromToolResult(ev)); default -> { } } } return out; } /** agent 路径的 提问/表单/提议 不再单独落显示事件——从工具结果同源推导。 */ private String historyFromToolResult(Map ev) { String name = str(ev.get("name")); if (!"askUser".equals(name) && !"collectForm".equals(name) && !"proposeWrite".equals(name)) { return ""; } try { JsonNode r = mapper.readTree(str(ev.get("text"))); if ("askUser".equals(name) && "question".equals(r.path("type").asText(""))) { return r.path("question").asText(""); } if ("collectForm".equals(name) && "form_collect".equals(r.path("type").asText(""))) { return "【表单】新建" + r.path("entity").asText("") + ":" + r.path("message").asText(""); } if ("proposeWrite".equals(name) && !r.path("opId").asText("").isBlank()) { return "【待确认】" + r.path("summary").asText(""); } } catch (Exception ignore) { } return ""; } // ---------------------------------------------------------------- 流程卡(② 段) /** * 进行中流程卡:激活 skill 全文(最近一次 {@code skill_active},被 {@code skill_done} 或更新的 * skill 覆盖前一直钉住)+ 在办单据状态(表单待提交 / 提议待确认;executed/cancelled/failed 即摘下)。 * 两者皆无 → null。 */ public String activeCard(List> events) { String skillText = null; Map lastFlow = null; // form/collectForm/proposal/confirm/cancel/form_submit 中最近的一个 String proposalOpId = null; String proposalSummary = null; for (Map ev : events) { String type = str(ev.get("type")); switch (type) { case "skill_active" -> skillText = str(ev.get("text")); case "skill_done" -> skillText = null; case "form", "form_submit", "proposal" -> lastFlow = ev; case "confirm", "cancel" -> { // 流程终结:单据线与技能线一起摘下 lastFlow = ev; skillText = null; } case "tool_result" -> { String name = str(ev.get("name")); if ("collectForm".equals(name) || "proposeWrite".equals(name)) { Map derived = deriveFlowEvent(ev); if (derived != null) { lastFlow = derived; } } } default -> { } } if (lastFlow != null && "proposal".equals(str(lastFlow.get("type")))) { proposalOpId = str(lastFlow.get("opId")); proposalSummary = str(lastFlow.get("summary")); } } String docLine = null; if (lastFlow != null) { switch (str(lastFlow.get("type"))) { case "form" -> docLine = "已为「" + str(lastFlow.get("entity")) + "」弹出新建表单,等待用户填写提交。"; case "proposal" -> docLine = "待确认提议:" + proposalSummary + "(opId=" + proposalOpId + ",用户点【确认】才会执行)。"; default -> { } // form_submit/confirm/cancel = 流程已推进/终结,卡摘下 } } if (skillText == null && docLine == null) { return null; } StringBuilder sb = new StringBuilder(); if (skillText != null) { sb.append(skillText); } if (docLine != null) { if (sb.length() > 0) { sb.append("\n"); } sb.append("在办单据:").append(docLine); } return sb.toString(); } /** 工具结果 → 流程事件(collectForm 弹表单 / proposeWrite 出提议),供流程卡推导。 */ private Map deriveFlowEvent(Map ev) { try { JsonNode r = mapper.readTree(str(ev.get("text"))); if ("collectForm".equals(str(ev.get("name"))) && "form_collect".equals(r.path("type").asText(""))) { Map m = new LinkedHashMap<>(); m.put("type", "form"); m.put("entity", r.path("entity").asText("")); return m; } if ("proposeWrite".equals(str(ev.get("name"))) && !r.path("opId").asText("").isBlank()) { Map m = new LinkedHashMap<>(); m.put("type", "proposal"); m.put("opId", r.path("opId").asText("")); m.put("summary", r.path("summary").asText("")); return m; } } catch (Exception ignore) { } return null; } // ---------------------------------------------------------------- 内部 /** 按用户事件(user/form_submit)分轮;轮前遗留事件并入首轮。 */ private static List>> groupTurns(List> events) { List>> turns = new ArrayList<>(); List> cur = new ArrayList<>(); for (Map ev : events) { String type = str(ev.get("type")); if (("user".equals(type) || "form_submit".equals(type)) && !cur.isEmpty()) { turns.add(cur); cur = new ArrayList<>(); } cur.add(ev); } if (!cur.isEmpty()) { turns.add(cur); } return turns; } /** 一轮 → 一行确定性摘要:用户说了什么 ⇒ 结果是什么。 */ private String digestLine(List> turn) { String userText = ""; String outcome = ""; for (Map ev : turn) { String type = str(ev.get("type")); switch (type) { case "user" -> userText = str(ev.get("text")); case "form_submit" -> userText = renderFormSubmit(ev); case "ai", "assistant", "clarify" -> outcome = str(ev.get("text")); case "question" -> outcome = "问:" + str(ev.get("question")); case "form" -> outcome = "弹出「" + str(ev.get("entity")) + "」新建表单"; case "proposal" -> outcome = "生成待确认提议:" + str(ev.get("summary")); case "confirm" -> outcome = ("executed".equals(str(ev.get("status"))) ? "用户确认,执行成功:" : "确认后执行失败:") + str(ev.get("description")); case "cancel" -> outcome = "用户取消:" + str(ev.get("description")); case "tool_result" -> { String h = historyFromToolResult(ev); if (!h.isBlank()) { outcome = h; } } default -> { } } } if (userText.isBlank() && outcome.isBlank()) { return ""; } return clip(userText, 40) + (outcome.isBlank() ? "" : " ⇒ " + clip(outcome, DIGEST_LINE_LEN)); } private ChatMessage fromPayload(String payload) { try { List ms = ChatMessageDeserializer.messagesFromJson(payload); return ms.isEmpty() ? null : ms.get(0); } catch (Exception e) { return null; } } private String renderFormSubmit(Map ev) { return "提交「" + str(ev.get("entity")) + "」新增表单:" + str(ev.get("fields")); } private String renderOutcome(Map ev) { String desc = str(ev.get("description")); if ("cancel".equals(str(ev.get("type")))) { return "(系统)用户取消了该操作:" + desc; } if ("executed".equals(str(ev.get("status")))) { return "(系统)用户已确认,操作执行成功:" + desc; } String msg = str(ev.get("msg")); return "(系统)用户确认后执行失败:" + desc + (msg.isBlank() ? "" : ",原因:" + msg); } private static int approxLen(Map ev) { String type = str(ev.get("type")); if ("tool_result".equals(type)) { return str(ev.get("digest")).length() + 20; // 旧轮只进摘要 } if ("tool_call".equals(type)) { return str(ev.get("payload")).length() / 2 + 20; } int n = 0; for (Object v : ev.values()) { if (v instanceof String s) { n += s.length(); } } return Math.max(n, 20); } private static void addAi(List out, String text) { if (text != null && !text.isBlank()) { out.add(AiMessage.from(text)); } } private static void addHist(List> out, String text) { if (text != null && !text.isBlank()) { out.add(Map.of("role", "ai", "content", text)); } } private static String clip(String s, int max) { String t = s == null ? "" : s.replace('\n', ' ').trim(); return t.length() > max ? t.substring(0, max) + "…" : t; } private static String parenOr(String msg) { return msg == null || msg.isBlank() ? "" : ("(" + msg + ")"); } private static String str(Object o) { return o == null ? "" : o.toString(); } }