EventProjectionService.java 16.5 KB
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} 的事件流,
 * 文案在这一处生成,不再散落在各控制器里。
 *
 * <p><b>LLM 上下文四段式</b>({@link #project}):
 * <ol>
 *   <li>稳定 system prompt(KV 前缀稳定);</li>
 *   <li>进行中流程卡(激活 skill 全文 + 在办单据状态)——钉在 system 尾部,不参与截断,
 *       提议 executed/cancelled 或新 skill 激活时摘下;</li>
 *   <li>往事摘要区:预算外旧轮 → 确定性一行摘要(零模型调用);</li>
 *   <li>近期原文区:约 charBudget 字符按**整轮**纳入;旧轮工具结果压 {@value #TOOL_DIGEST_LEN} 字;
 *       当前轮(最后一个用户事件起)原样载荷精确重建,工具调用/结果配对不可破坏。</li>
 * </ol>
 */
@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<ChatMessage> project(String systemText, List<Map<String, Object>> events, int charBudget) {
        List<List<Map<String, Object>>> 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<String, Object> ev : turns.get(i)) {
                size += approxLen(ev);
            }
            if (used + size > charBudget) {
                break;
            }
            used += size;
            firstVerbatim = i;
        }

        List<ChatMessage> 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<ChatMessage> out, List<Map<String, Object>> turn, boolean isCurrent) {
        for (Map<String, Object> 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<Map<String, String>> historyView(List<Map<String, Object>> events) {
        List<Map<String, String>> out = new ArrayList<>();
        for (Map<String, Object> 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<String, Object> 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<Map<String, Object>> events) {
        String skillText = null;
        Map<String, Object> lastFlow = null; // form/collectForm/proposal/confirm/cancel/form_submit 中最近的一个
        String proposalOpId = null;
        String proposalSummary = null;

        for (Map<String, Object> 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<String, Object> 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<String, Object> deriveFlowEvent(Map<String, Object> 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<String, Object> 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<String, Object> 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<List<Map<String, Object>>> groupTurns(List<Map<String, Object>> events) {
        List<List<Map<String, Object>>> turns = new ArrayList<>();
        List<Map<String, Object>> cur = new ArrayList<>();
        for (Map<String, Object> 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<Map<String, Object>> turn) {
        String userText = "";
        String outcome = "";
        for (Map<String, Object> 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<ChatMessage> ms = ChatMessageDeserializer.messagesFromJson(payload);
            return ms.isEmpty() ? null : ms.get(0);
        } catch (Exception e) {
            return null;
        }
    }

    private String renderFormSubmit(Map<String, Object> ev) {
        return "提交「" + str(ev.get("entity")) + "」新增表单:" + str(ev.get("fields"));
    }

    private String renderOutcome(Map<String, Object> 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<String, Object> 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<ChatMessage> out, String text) {
        if (text != null && !text.isBlank()) {
            out.add(AiMessage.from(text));
        }
    }

    private static void addHist(List<Map<String, String>> 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();
    }
}