mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
fix(chat): supersede pre-tool content segments by structure, not text patterns
This commit is contained in:
parent
046080d6aa
commit
29a1edc3b1
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user