diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/SegmentSupersedeDetector.java b/mateclaw-server/src/main/java/vip/mate/channel/web/SegmentSupersedeDetector.java index c3fe20d6..a783110e 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/SegmentSupersedeDetector.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/SegmentSupersedeDetector.java @@ -1,24 +1,28 @@ package vip.mate.channel.web; import java.util.List; -import java.util.Locale; import java.util.Map; -import java.util.regex.Pattern; /** - * Marks model-predicted tool results that are replaced by the actual post-tool - * answer segment. + * Marks assistant content emitted before its tool calls ran as superseded + * by the post-tool content that follows. + * + *

The rule is purely structural — no text inspection. A content segment that + * (a) does not directly follow a tool result and (b) is followed by a tool call + * before any other content segment was produced in the same model completion as + * those tool calls. Whatever it says — process narration, a predicted result, or + * an answer copied from stale conversation history — it is not grounded in this + * turn's observations. When any content segment exists after that tool call + * (the answer written with the actual results in hand), the pre-tool segment is + * marked superseded so renderers collapse it in favor of the grounded answer. + * + *

Content that directly follows a tool result is never marked: it was written + * after observing real output and may carry standalone value (e.g. a download + * link for an intermediate artifact in a multi-file run). */ final class SegmentSupersedeDetector { - static final String REASON_TOOL_RESULT_REPLACED_MODEL_CLAIM = "tool_result_replaced_model_claim"; - - private static final Pattern GENERATED_FILE_URL = - Pattern.compile("(?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+"); - private static final Pattern BYTE_COUNT = - Pattern.compile("\\d+\\s*字节"); - private static final Pattern REPLACEMENT_COUNT = - Pattern.compile("\\d+\\s*处"); + static final String REASON_PRE_TOOL_CONTENT_REPLACED = "pre_tool_content_replaced_by_post_tool_answer"; private SegmentSupersedeDetector() { } @@ -35,22 +39,18 @@ final class SegmentSupersedeDetector { continue; } - Claim predictedClaim = parseClaim(String.valueOf(candidate.getOrDefault("text", ""))); - if (predictedClaim == null) { - continue; - } - int toolIndex = nextToolIndexBeforeContent(segments, i + 1); if (toolIndex < 0) { continue; } - Map tool = segments.get(toolIndex); - if (Boolean.FALSE.equals(tool.get("toolSuccess")) - || !toolMatchesClaim(String.valueOf(tool.getOrDefault("toolName", "")), predictedClaim)) { - continue; - } - int replacementIndex = nextMatchingContentIndex(segments, toolIndex + 1, predictedClaim); + // The replacement is the first content segment written after the tool + // ran — grounded in its observation. Later tool calls may sit in + // between (parallel or chained calls from the same completion), so the + // scan crosses tool boundaries. Tool success is irrelevant: on failure + // the post-tool content carries the authoritative failure explanation, + // which supersedes an optimistic pre-tool claim all the same. + int replacementIndex = nextContentIndex(segments, toolIndex + 1); if (replacementIndex < 0) { continue; } @@ -58,10 +58,16 @@ final class SegmentSupersedeDetector { Map replacement = segments.get(replacementIndex); candidate.put("superseded", true); candidate.put("supersededBySegmentId", String.valueOf(replacement.getOrDefault("id", ""))); - candidate.put("supersededReason", REASON_TOOL_RESULT_REPLACED_MODEL_CLAIM); + candidate.put("supersededReason", REASON_PRE_TOOL_CONTENT_REPLACED); } } + /** + * Index of the next tool_call segment after {@code start}, or -1 when a + * content segment appears first — a following content segment means the + * candidate closed its completion without issuing tool calls, so it is not + * pre-tool narration. + */ private static int nextToolIndexBeforeContent(List> segments, int start) { for (int i = start; i < segments.size(); i++) { Map segment = segments.get(i); @@ -75,6 +81,7 @@ final class SegmentSupersedeDetector { return -1; } + /** Whether the nearest preceding non-thinking segment is a tool call. */ private static boolean followsToolResult(List> segments, int index) { for (int i = index - 1; i >= 0; i--) { Map segment = segments.get(i); @@ -88,17 +95,10 @@ final class SegmentSupersedeDetector { return false; } - private static int nextMatchingContentIndex(List> segments, int start, Claim predictedClaim) { + /** First content segment at or after {@code start}, crossing tool boundaries; -1 when none. */ + private static int nextContentIndex(List> segments, int start) { for (int i = start; i < segments.size(); i++) { - Map segment = segments.get(i); - if (isToolCall(segment)) { - return -1; - } - if (!isContent(segment)) { - continue; - } - Claim actualClaim = parseClaim(String.valueOf(segment.getOrDefault("text", ""))); - if (predictedClaim.sameKind(actualClaim)) { + if (isContent(segments.get(i))) { return i; } } @@ -112,41 +112,4 @@ final class SegmentSupersedeDetector { private static boolean isToolCall(Map segment) { return segment != null && "tool_call".equals(segment.get("type")); } - - private static Claim parseClaim(String text) { - if (text == null || text.isBlank()) { - return null; - } - String upper = text.toUpperCase(Locale.ROOT); - if ((upper.contains("成功生成") || text.contains("已生成")) && GENERATED_FILE_URL.matcher(text).find()) { - for (String format : List.of("PDF", "DOCX", "PPTX", "XLSX")) { - if (upper.contains(format)) { - return new Claim("render", format); - } - } - } - if (text.contains("成功写入") && BYTE_COUNT.matcher(text).find()) { - return new Claim("write", ""); - } - if (text.contains("成功替换") && REPLACEMENT_COUNT.matcher(text).find()) { - return new Claim("edit", ""); - } - return null; - } - - private static boolean toolMatchesClaim(String toolName, Claim claim) { - String normalized = toolName == null ? "" : toolName.toLowerCase(Locale.ROOT); - return switch (claim.type) { - case "render" -> normalized.contains("render" + claim.detail.toLowerCase(Locale.ROOT)); - case "write" -> "write_file".equals(normalized); - case "edit" -> "edit_file".equals(normalized); - default -> false; - }; - } - - private record Claim(String type, String detail) { - boolean sameKind(Claim other) { - return other != null && type.equals(other.type) && detail.equals(other.detail); - } - } } diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/SegmentSupersedeDetectorTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/SegmentSupersedeDetectorTest.java index b1a4819f..1f7a572b 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/web/SegmentSupersedeDetectorTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/web/SegmentSupersedeDetectorTest.java @@ -25,12 +25,31 @@ class SegmentSupersedeDetectorTest { assertThat(segments.get(0)) .containsEntry("superseded", true) .containsEntry("supersededBySegmentId", "ct-1") - .containsEntry("supersededReason", "tool_result_replaced_model_claim"); + .containsEntry("supersededReason", SegmentSupersedeDetector.REASON_PRE_TOOL_CONTENT_REPLACED); } @Test - @DisplayName("does not mark legitimate preamble before a render tool") - void leavesLegitimatePreambleAlone() { + @DisplayName("marks stale status answer emitted before this turn's status query ran") + void marksStaleStatusAnswer() { + List> segments = segments( + thinking("th-0"), + content("ct-0", "中控测试会议室当前无人。人数 0,电池 0%。查询时间 2026-07-31 17:23。"), + tool("tc-0", "getCurrentTime", true), + tool("tc-1", "executeCode", true), + content("ct-1", "中控测试会议室当前无人。人数 0,电池 0%。查询时间 2026-08-04 11:02。")); + + SegmentSupersedeDetector.markSuperseded(segments); + + assertThat(segments.get(1)) + .containsEntry("superseded", true) + .containsEntry("supersededBySegmentId", "ct-1") + .containsEntry("supersededReason", SegmentSupersedeDetector.REASON_PRE_TOOL_CONTENT_REPLACED); + assertThat(segments.get(4)).doesNotContainKey("superseded"); + } + + @Test + @DisplayName("marks pre-tool preamble narration once the grounded answer exists") + void marksPreamble() { List> segments = segments( content("ct-0", "我听懂了,需要生成 PDF。让我立即执行这个操作:"), tool("tc-0", "renderPdf", true), @@ -38,27 +57,14 @@ class SegmentSupersedeDetectorTest { SegmentSupersedeDetector.markSuperseded(segments); - assertThat(segments.get(0)).doesNotContainKey("superseded"); - } - - @Test - @DisplayName("marks pre-tool forged write byte count when replaced by real write result") - void marksForgedWriteSuccess() { - List> segments = segments( - content("ct-0", "文件已成功写入!\n\n写入字节数:45 字节"), - tool("tc-0", "write_file", true), - content("ct-1", "文件已成功写入!\n\n写入字节数:43 字节")); - - SegmentSupersedeDetector.markSuperseded(segments); - assertThat(segments.get(0)) .containsEntry("superseded", true) .containsEntry("supersededBySegmentId", "ct-1"); } @Test - @DisplayName("does not mark pre-tool success when the tool failed") - void leavesFailedToolClaimVisible() { + @DisplayName("marks pre-tool forged success even when the tool failed — the failure explanation supersedes it") + void marksForgedClaimWhenToolFailed() { List> segments = segments( content("ct-0", "PPTX 文件已成功生成!\n\n下载链接: /api/v1/files/generated/c8e2f4a1-9b3d-4f8c-a5e7-d9f6b2c1a3e4"), tool("tc-0", "renderPptx", false), @@ -66,38 +72,29 @@ class SegmentSupersedeDetectorTest { SegmentSupersedeDetector.markSuperseded(segments); - assertThat(segments.get(0)).doesNotContainKey("superseded"); + assertThat(segments.get(0)) + .containsEntry("superseded", true) + .containsEntry("supersededBySegmentId", "ct-1"); } @Test - @DisplayName("v1 does not mark when the post-tool content is a general summary") - void leavesSummaryFollowupAlone() { - List> segments = segments( - content("ct-0", "文件内容已成功替换!\n\n替换次数:1 处"), - tool("tc-0", "edit_file", true), - content("ct-1", "所有文档生成和文件操作任务已完成。")); - - SegmentSupersedeDetector.markSuperseded(segments); - - assertThat(segments.get(0)).doesNotContainKey("superseded"); - } - - @Test - @DisplayName("does not cross another tool boundary looking for a replacement") - void doesNotCrossToolBoundary() { + @DisplayName("crosses chained tool boundaries to find the grounded replacement") + void crossesToolBoundaries() { List> segments = segments( content("ct-0", "XLSX 文件已成功生成!\n\n下载链接: /api/v1/files/generated/8c3d4a9f-2e1b-4f5a-b6c7-d8e9f0a1b2c3"), tool("tc-0", "renderXlsx", true), tool("tc-1", "renderDocx", true), - content("ct-1", "XLSX 文件已成功生成!\n\n下载链接: /api/v1/files/generated/f98d7fd0-3cda-4510-b056-5bd3c8343e19")); + content("ct-1", "两个文件均已生成。")); SegmentSupersedeDetector.markSuperseded(segments); - assertThat(segments.get(0)).doesNotContainKey("superseded"); + assertThat(segments.get(0)) + .containsEntry("superseded", true) + .containsEntry("supersededBySegmentId", "ct-1"); } @Test - @DisplayName("does not mark an actual post-tool result as a later pre-tool prediction") + @DisplayName("never marks content that directly follows a tool result — it is grounded and may carry real links") void doesNotMarkPostToolResult() { List> segments = segments( tool("tc-0", "renderDocx", true), @@ -108,6 +105,35 @@ class SegmentSupersedeDetectorTest { SegmentSupersedeDetector.markSuperseded(segments); assertThat(segments.get(1)).doesNotContainKey("superseded"); + assertThat(segments.get(3)).doesNotContainKey("superseded"); + } + + @Test + @DisplayName("leaves pre-tool content visible when the run produced no post-tool answer") + void leavesContentWhenNoPostToolAnswer() { + List> segments = segments( + content("ct-0", "我先查询会议室状态。"), + tool("tc-0", "executeCode", true), + thinking("th-0")); + + SegmentSupersedeDetector.markSuperseded(segments); + + assertThat(segments.get(0)).doesNotContainKey("superseded"); + } + + @Test + @DisplayName("does not treat a completion that closed without tool calls as pre-tool narration") + void doesNotMarkAnswerBeforeLaterContent() { + List> segments = segments( + content("ct-0", "第一部分答案。"), + content("ct-1", "第二部分答案。"), + tool("tc-0", "write_file", true), + content("ct-2", "文件已保存。")); + + SegmentSupersedeDetector.markSuperseded(segments); + + assertThat(segments.get(0)).doesNotContainKey("superseded"); + assertThat(segments.get(1)).containsEntry("superseded", true); } @SafeVarargs @@ -121,6 +147,12 @@ class SegmentSupersedeDetectorTest { return segment; } + private static Map thinking(String id) { + Map segment = base(id, "thinking"); + segment.put("thinkingText", "…"); + return segment; + } + private static Map tool(String id, String toolName, boolean success) { Map segment = base(id, "tool_call"); segment.put("toolName", toolName);