fix(chat): supersede pre-tool content segments by structure, not text patterns

This commit is contained in:
matevip 2026-08-04 03:46:32 -04:00
parent 046080d6aa
commit 29a1edc3b1
2 changed files with 103 additions and 108 deletions

View File

@ -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 <em>before</em> its tool calls ran as superseded
* by the post-tool content that follows.
*
* <p>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.
*
* <p>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<String, Object> 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<String, Object> 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<Map<String, Object>> segments, int start) {
for (int i = start; i < segments.size(); i++) {
Map<String, Object> 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<Map<String, Object>> segments, int index) {
for (int i = index - 1; i >= 0; i--) {
Map<String, Object> segment = segments.get(i);
@ -88,17 +95,10 @@ final class SegmentSupersedeDetector {
return false;
}
private static int nextMatchingContentIndex(List<Map<String, Object>> segments, int start, Claim predictedClaim) {
/** First content segment at or after {@code start}, crossing tool boundaries; -1 when none. */
private static int nextContentIndex(List<Map<String, Object>> segments, int start) {
for (int i = start; i < segments.size(); i++) {
Map<String, Object> 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<String, Object> 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);
}
}
}

View File

@ -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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> thinking(String id) {
Map<String, Object> segment = base(id, "thinking");
segment.put("thinkingText", "");
return segment;
}
private static Map<String, Object> tool(String id, String toolName, boolean success) {
Map<String, Object> segment = base(id, "tool_call");
segment.put("toolName", toolName);