EventProjectionService.java 26 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574
package com.xly.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.langchain4j.agent.tool.ToolExecutionRequest;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.UnaryOperator;

/**
 * 事件账本的**唯一**投影/渲染器 —— 前端历史与 LLM 上下文同源于 {@link LedgerService} 的事件流,
 * 文案在这一处生成,不再散落在各控制器里。
 *
 * <p><b>LLM 上下文四段式</b>({@link #project}),预算按 <b>token</b> 记账(本地保守估算
 * {@link TokenEstimator},绝对值由 {@code llm.context-length}(镜像 Ollama 的
 * OLLAMA_CONTEXT_LENGTH,经 OpenAI 兼容通道无法逐请求传 num_ctx)导出):
 * <ol>
 *   <li>① 稳定 system prompt + ② 进行中流程卡 —— 永不让位;</li>
 *   <li>④ 往事摘要区:预算外旧轮 → 确定性一行摘要(零模型调用);</li>
 *   <li>⑤ 近期原文区:按**整轮**纳入,旧轮工具结果压 {@value #TOOL_DIGEST_LEN} 字;</li>
 *   <li>当前轮:原样保真、工具配对不可破坏;当前轮自身超限时**轮内让位**——最早的工具结果
 *       降为摘要(保最近 {@value #CURRENT_KEEP_FULL} 个全文),单条结果超硬顶截断。</li>
 * </ol>
 * 组装后做**发送前自检**:总 token 超预算 → ⑤ 最旧轮退入 ④、再裁 ④ 最旧行;①②与当前轮不动。
 * 绝不把超限上下文交给 Ollama 静默截头(它从最前截、① 先死)。
 */
@Service
public class EventProjectionService {

    private static final Logger log = LoggerFactory.getLogger(EventProjectionService.class);

    public static final int TOOL_DIGEST_LEN = 120;
    private static final int DIGEST_TURNS_MAX = 40;
    private static final int DIGEST_LINE_LEN = 90;

    /** 模型输出预留(num_ctx 覆盖 prompt+生成)。 */
    private static final int OUTPUT_RESERVE = 1024;
    /** 工具 schema(随每次请求发送、不在 messages 里)的估算开销。 */
    private static final int TOOLS_OVERHEAD = 2000;
    /** 当前轮增长的乐观小额预留(每次工具往返都会重投影,超出时反应式收缩⑤)。 */
    private static final int TURN_RESERVE = 512;
    /** ④ 摘要区 token 上限。 */
    private static final int DIGEST_BUDGET = 800;
    /** 当前轮里保全文的最近工具结果个数(轮内让位时更早的降摘要)。 */
    private static final int CURRENT_KEEP_FULL = 2;
    /** 当前轮单条工具结果硬顶(token),超出截断。 */
    private static final int SINGLE_RESULT_CAP = 2400;

    private final ObjectMapper mapper;
    private final int contextLength;

    /** opId → 人类可读状态行(Phase C:流程卡只读展示 ai_op_queue.sStatus)。可空(测试/未装配)。 */
    private volatile UnaryOperator<String> opStatusLookup;

    public EventProjectionService(ObjectMapper mapper, @Value("${llm.context-length:16384}") int contextLength) {
        this.mapper = mapper;
        this.contextLength = contextLength;
    }

    public EventProjectionService(ObjectMapper mapper) {
        this(mapper, 16384);
    }

    public void setOpStatusLookup(UnaryOperator<String> lookup) {
        this.opStatusLookup = lookup;
    }

    /** 输入 prompt 的总预算(token)。 */
    int promptBudget() {
        return Math.max(2048, contextLength - OUTPUT_RESERVE - TOOLS_OVERHEAD);
    }

    // ---------------------------------------------------------------- LLM 投影

    /** 四段式 LLM 上下文(token 记账)。systemText 为空时不出 system 消息(如测试)。 */
    public List<ChatMessage> project(String systemText, List<Map<String, Object>> events) {
        int budget = promptBudget();
        List<List<Map<String, Object>>> turns = groupTurns(events);
        int current = turns.size() - 1;

        // ①② system + 流程卡(永不让位)
        SystemMessage sys = null;
        int sysEst = 0;
        if (systemText != null && !systemText.isBlank()) {
            String card = activeCard(events);
            sys = SystemMessage.from(card == null ? systemText
                    : systemText + "\n\n【进行中的流程】\n" + card);
            sysEst = TokenEstimator.estimate(sys);
        }

        // 当前轮:原样保真;自身超限时轮内让位
        List<ChatMessage> currentMsgs = new ArrayList<>();
        int curEst = 0;
        if (current >= 0) {
            int curAllowance = budget - sysEst - TURN_RESERVE;
            currentMsgs = renderCurrentTurn(turns.get(current), curAllowance);
            curEst = TokenEstimator.estimate(currentMsgs);
        }

        // ⑤ 近期原文区:从最新旧轮往回按整轮纳入
        List<List<ChatMessage>> oldRendered = new ArrayList<>(); // index 对齐 turns[0..current-1]
        int[] oldEst = new int[Math.max(0, current)];
        for (int i = 0; i < current; i++) {
            List<ChatMessage> ms = new ArrayList<>();
            appendTurnMessages(ms, turns.get(i), false);
            oldRendered.add(ms);
            oldEst[i] = TokenEstimator.estimate(ms);
        }
        int remaining = budget - sysEst - curEst - TURN_RESERVE;
        int firstVerbatim = fillRecent(oldEst, current, remaining, 0);
        if (firstVerbatim > 0) { // 有轮进摘要区 → 给 ④ 留预算后重算
            firstVerbatim = fillRecent(oldEst, current, remaining, DIGEST_BUDGET);
        }

        // ④ 摘要行
        List<String> digestLines = new ArrayList<>();
        int digestFrom = Math.max(0, firstVerbatim - DIGEST_TURNS_MAX);
        for (int i = digestFrom; i < firstVerbatim; i++) {
            String line = digestLine(turns.get(i));
            if (!line.isBlank()) {
                digestLines.add(line);
            }
        }
        int omitted = digestFrom;

        // 组装 + 发送前自检(⑤ 最旧退 ④ → 裁 ④ 最旧;①② 与当前轮不动)
        while (true) {
            List<ChatMessage> out = assemble(sys, digestLines, omitted, oldRendered, firstVerbatim, current, currentMsgs);
            int total = TokenEstimator.estimate(out);
            if (total <= budget) {
                return out;
            }
            if (firstVerbatim < current) {
                String line = digestLine(turns.get(firstVerbatim));
                if (!line.isBlank()) {
                    digestLines.add(line);
                }
                firstVerbatim++;
                continue;
            }
            if (!digestLines.isEmpty()) {
                digestLines.remove(0);
                omitted++;
                continue;
            }
            log.warn("projection over budget even after shrink: total≈{}t budget={}t (当前轮过大)", total, budget);
            return out;
        }
    }

    /** ⑤ 从最新旧轮往回装,返回 firstVerbatim(第一条进原文区的旧轮下标;current 表示一条都装不下)。 */
    private static int fillRecent(int[] oldEst, int current, int remaining, int digestAllowance) {
        int budget = remaining - digestAllowance;
        int used = 0;
        int first = current;
        for (int i = current - 1; i >= 0; i--) {
            if (used + oldEst[i] > budget) {
                break;
            }
            used += oldEst[i];
            first = i;
        }
        return first;
    }

    private List<ChatMessage> assemble(SystemMessage sys, List<String> digestLines, int omitted,
                                       List<List<ChatMessage>> oldRendered, int firstVerbatim, int current,
                                       List<ChatMessage> currentMsgs) {
        List<ChatMessage> out = new ArrayList<>();
        if (sys != null) {
            out.add(sys);
        }
        if (!digestLines.isEmpty() || omitted > 0) {
            StringBuilder sb = new StringBuilder("【更早对话摘要(自动截断,仅供参考)】");
            if (omitted > 0) {
                sb.append("\n(更早 ").append(omitted).append(" 轮已省略)");
            }
            for (String line : digestLines) {
                sb.append("\n- ").append(line);
            }
            out.add(UserMessage.from(sb.toString()));
        }
        for (int i = firstVerbatim; i < current; i++) {
            out.addAll(oldRendered.get(i));
        }
        out.addAll(currentMsgs);
        return out;
    }

    /**
     * 当前轮渲染:原样保真;总量超 allowance 时**轮内让位**——最早的工具结果先降
     * {@value #TOOL_DIGEST_LEN} 字摘要(保最近 {@value #CURRENT_KEEP_FULL} 个全文,仍超则只保 1 个)。
     * 工具调用/结果配对永不破坏,单条结果超 {@value #SINGLE_RESULT_CAP}t 截断。
     */
    private List<ChatMessage> renderCurrentTurn(List<Map<String, Object>> turn, int allowance) {
        for (int keepFull = CURRENT_KEEP_FULL; keepFull >= 0; keepFull--) {
            List<ChatMessage> ms = renderCurrentWith(turn, keepFull);
            if (TokenEstimator.estimate(ms) <= allowance || keepFull == 0) {
                return ms;
            }
        }
        return List.of();
    }

    private List<ChatMessage> renderCurrentWith(List<Map<String, Object>> turn, int keepFull) {
        int results = 0;
        for (Map<String, Object> ev : turn) {
            if ("tool_result".equals(str(ev.get("type")))) {
                results++;
            }
        }
        int demote = Math.max(0, results - keepFull);
        List<ChatMessage> out = new ArrayList<>();
        int seen = 0;
        for (Map<String, Object> ev : turn) {
            if ("tool_result".equals(str(ev.get("type")))) {
                seen++;
                String text = seen <= demote ? str(ev.get("digest")) : capped(str(ev.get("text")));
                out.add(ToolExecutionResultMessage.from(str(ev.get("tcId")), str(ev.get("name")), text));
            } else {
                appendOneMessage(out, ev, true);
            }
        }
        return out;
    }

    private static String capped(String text) {
        if (TokenEstimator.estimate(text) <= SINGLE_RESULT_CAP) {
            return text;
        }
        // 按估算规则回推一个安全字符数(全 CJK 最坏情形 = 1 字 1 token)
        return text.substring(0, Math.min(text.length(), SINGLE_RESULT_CAP)) + "……[超长已截断]";
    }

    /** 一轮 → LLM 消息。当前轮工具结果全文保真;旧轮压一行摘要。 */
    private void appendTurnMessages(List<ChatMessage> out, List<Map<String, Object>> turn, boolean isCurrent) {
        for (Map<String, Object> ev : turn) {
            if ("tool_result".equals(str(ev.get("type")))) {
                String text = isCurrent ? str(ev.get("text")) : str(ev.get("digest"));
                out.add(ToolExecutionResultMessage.from(str(ev.get("tcId")), str(ev.get("name")), text));
            } else {
                appendOneMessage(out, ev, isCurrent);
            }
        }
    }

    private void appendOneMessage(List<ChatMessage> out, Map<String, Object> ev, boolean isCurrent) {
        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 = toolCallMessage(ev);
                if (m != null) {
                    out.add(m);
                }
            }
            case "question" -> addAi(out, str(ev.get("question")));
            case "form" -> addAi(out, "已为「" + str(ev.get("entity")) + "」弹出新建表单,等待用户填写提交。");
            case "queued" -> addAi(out, renderQueued(ev));
            case "proposal" -> addAi(out, "已生成待确认提议:" + str(ev.get("summary")) + "(等待用户点确认/取消,尚未执行)");
            case "confirm", "cancel" -> addAi(out, renderOutcome(ev));
            default -> { } // tool(旧版摘要)/skill_active/skill_done 不进正文(skill 由流程卡承载)
        }
    }

    /** tool_call 事件 → AiMessage:优先中立格式 calls=[{id,name,args}],回退旧版 LangChain4j 序列化载荷。 */
    private ChatMessage toolCallMessage(Map<String, Object> ev) {
        Object calls = ev.get("calls");
        if (calls instanceof List<?> list && !list.isEmpty()) {
            List<ToolExecutionRequest> reqs = new ArrayList<>();
            for (Object o : list) {
                if (o instanceof Map<?, ?> m) {
                    reqs.add(ToolExecutionRequest.builder()
                            .id(str(m.get("id")))
                            .name(str(m.get("name")))
                            .arguments(str(m.get("args")))
                            .build());
                }
            }
            if (!reqs.isEmpty()) {
                String text = str(ev.get("text"));
                return text.isBlank() ? AiMessage.from(reqs) : AiMessage.from(text, reqs);
            }
        }
        return fromPayload(str(ev.get("payload")));
    }

    // ---------------------------------------------------------------- 前端历史投影

    /** 前端历史(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 "queued" -> addHist(out, "【已提交待办】" + str(ev.get("description")));
                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)
                && !"previewChange".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 ("previewChange".equals(name) && "change_preview".equals(r.path("type").asText(""))) {
                return "【预览】" + r.path("summary").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 覆盖前一直钉住)+ 在办单据状态(表单待提交 / 预览待保存 / 已提交待办的只读进度)。
     * {@code queued} 后技能线摘下(AI 侧流程到写入队列为止),待办行保留到下一个流程事件替换。
     * 两者皆无 → null。
     */
    public String activeCard(List<Map<String, Object>> events) {
        String skillText = null;
        Map<String, Object> lastFlow = 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 "queued", "confirm", "cancel" -> { // AI 侧流程终结:技能线摘下
                    lastFlow = ev;
                    skillText = null;
                }
                case "tool_result" -> {
                    Map<String, Object> derived = deriveFlowEvent(ev);
                    if (derived != null) {
                        lastFlow = derived;
                    }
                }
                default -> { }
            }
        }

        String docLine = null;
        if (lastFlow != null) {
            switch (str(lastFlow.get("type"))) {
                case "form" -> docLine = "已为「" + str(lastFlow.get("entity")) + "」弹出新建表单,等待用户填写提交。";
                case "preview" -> docLine = "已生成写操作预览卡:" + str(lastFlow.get("summary"))
                        + "(等待用户点卡上按钮提交,**尚未写入**)。";
                case "queued" -> docLine = renderQueuedLine(lastFlow);
                case "proposal" -> docLine = "待确认提议:" + str(lastFlow.get("summary"))
                        + "(opId=" + str(lastFlow.get("opId")) + ",用户点【确认】才会执行)。";
                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 弹表单 / previewChange 出预览 / 旧版 proposeWrite),供流程卡推导。 */
    private Map<String, Object> deriveFlowEvent(Map<String, Object> ev) {
        String name = str(ev.get("name"));
        if (!"collectForm".equals(name) && !"previewChange".equals(name) && !"proposeWrite".equals(name)) {
            return null;
        }
        try {
            JsonNode r = mapper.readTree(str(ev.get("text")));
            if ("collectForm".equals(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 ("previewChange".equals(name) && "change_preview".equals(r.path("type").asText(""))) {
                Map<String, Object> m = new LinkedHashMap<>();
                m.put("type", "preview");
                m.put("previewId", r.path("previewId").asText(""));
                m.put("summary", r.path("summary").asText(""));
                return m;
            }
            if ("proposeWrite".equals(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 "queued" -> outcome = "已提交待办:" + str(ev.get("description"));
                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 renderQueued(Map<String, Object> ev) {
        return "(系统)用户已保存,操作已提交待办:" + str(ev.get("description"))
                + "(AI 侧流程到此为止,是否/何时执行由 ERP 处理;绝不声称已执行成功)";
    }

    /** queued 流程卡行:附 ai_op_queue.sStatus 只读进度(可用时)。 */
    private String renderQueuedLine(Map<String, Object> ev) {
        String desc = str(ev.get("description"));
        String status = null;
        UnaryOperator<String> lookup = opStatusLookup;
        Object opIds = ev.get("opIds");
        if (lookup != null && opIds instanceof List<?> ids && !ids.isEmpty()) {
            try {
                status = lookup.apply(String.valueOf(ids.get(0)));
            } catch (Exception ignore) {
            }
        }
        return "已提交待办:" + desc + "(当前状态:" + (status == null || status.isBlank() ? "等待 ERP 处理" : status)
                + ";只读进度,勿再重复提交)。";
    }

    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 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();
    }
}