mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
The PRIVATE_ITEMS list contained the bare 'test' entry, which rsync interprets as 'any directory named test at any depth' — so it caught the root-level /test/ scratch directory (intended) AND every src/test/ under each module (not intended). Pattern is already anchored to /test (root-only). This commit rsyncs the accumulated src/test/ tree forward so opensource has the unit tests that have been written / updated against existing src/main/ code since the pattern regression. Going forward each per-commit sync will carry src/test/ files along with the main change.
220 lines
11 KiB
Java
220 lines
11 KiB
Java
package vip.mate.agent.graph;
|
||
|
||
import org.junit.jupiter.api.DisplayName;
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
import static org.junit.jupiter.api.Assertions.*;
|
||
|
||
/**
|
||
* Pin {@link NodeStreamingChatHelper#hasRepeatingSuffix} — the cheap
|
||
* loop detector that catches reasoning-mode models (qwen3.6, deepseek-r1)
|
||
* stuck emitting the same final-answer paragraph over and over.
|
||
*
|
||
* <p>Real failure pattern from production: model alternates English
|
||
* "Wait, I should X. Done. I will write the response." with the same
|
||
* Chinese answer, dozens of times, until {@code max_tokens} runs out.
|
||
* Without this guard the user waits for a wall of duplicated text;
|
||
* with it, the stream stops at the third or fourth copy and the
|
||
* already-accumulated content gets returned as a partial answer.
|
||
*
|
||
* <p>The detector probes periods from 24 chars (anything shorter would
|
||
* false-positive on natural phrases) up to 240 chars; 4 verbatim
|
||
* consecutive copies is the threshold (3-times structured outputs like
|
||
* "TL;DR / body / TL;DR again" should pass through).
|
||
*/
|
||
class ContentRepetitionGuardTest {
|
||
|
||
private static final int MIN_PERIOD = 24;
|
||
private static final int MAX_PERIOD = 240;
|
||
private static final int MIN_OCCURRENCES = 4;
|
||
|
||
@Test
|
||
@DisplayName("non-cyclic prose with varied sentences does NOT trip")
|
||
void naturalProseDoesNotTrip() {
|
||
// Real writing: each sentence is unique, no consecutive paragraph
|
||
// repeats anywhere in the buffer.
|
||
String prose = "MateClaw 是一个企业级 AI 助手。它支持多种渠道接入,包括企业微信、"
|
||
+ "飞书、钉钉。Agent 通过 StateGraph 编排,可以调用工具、生成图片、查询知识库。"
|
||
+ "用户可以在 Web 控制台、桌面 App 或群聊里发起对话。系统记忆采用三档分层:"
|
||
+ "PROFILE.md 记录用户画像、MEMORY.md 沉淀稳定事实、memory/YYYY-MM-DD.md "
|
||
+ "保存当日上下文。审批流程基于 Spring AI Alibaba Graph,工具调用前会被守卫拦截,"
|
||
+ "高风险操作必须由用户显式批准才能执行。会话与频道之间是多对多关系。";
|
||
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||
prose, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("verbatim short paragraph repeated 4× → trips")
|
||
void verbatimQuadrupleRepeatTrips() {
|
||
// The exact production failure mode: 50-char Chinese answer repeated.
|
||
String paragraph = "收到语音啦!想查昨天的天气没问题,告诉我城市名我马上帮你查!\n";
|
||
StringBuilder sb = new StringBuilder();
|
||
for (int i = 0; i < 5; i++) sb.append(paragraph);
|
||
assertTrue(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||
sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES),
|
||
"5 verbatim ~30-char paragraphs in a row should trip");
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("3 verbatim repeats stay UNDER threshold (legitimate triple-mention pattern)")
|
||
void threeRepeatsBelowThreshold() {
|
||
// Some legitimate outputs repeat structured summaries 2-3 times
|
||
// (e.g. "TL;DR" + body + "TL;DR" again). The threshold of 4
|
||
// gives breathing room so these don't false-positive.
|
||
String paragraph = "请告诉我您所在的城市,例如北京、上海或深圳,我可以为您查询天气。\n";
|
||
StringBuilder sb = new StringBuilder();
|
||
for (int i = 0; i < 3; i++) sb.append(paragraph);
|
||
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||
sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES),
|
||
"3 verbatim paragraphs must NOT trip — preserves triple-mention outputs");
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("interleaved English thinking + Chinese answer pattern still trips")
|
||
void interleavedRepetitionTrips() {
|
||
// Mirrors the production trace exactly: English thinking
|
||
// alternating with the same Chinese answer. The combined
|
||
// "thinking + answer" unit is the actual repeating period.
|
||
String unit = "Wait, I should write.\nOkay.\n收到语音啦!告诉我城市名我马上帮你查!\n";
|
||
StringBuilder sb = new StringBuilder();
|
||
for (int i = 0; i < 5; i++) sb.append(unit);
|
||
assertTrue(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||
sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES),
|
||
"5 verbatim 'thinking + answer' cycles should trip");
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("empty / short / null content returns false (fast path)")
|
||
void shortContentDoesNotTrip() {
|
||
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||
null, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES));
|
||
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||
"", MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES));
|
||
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||
"hi there", MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES));
|
||
// Just under MIN_PERIOD × MIN_OCCURRENCES → can't possibly match.
|
||
StringBuilder sb = new StringBuilder();
|
||
for (int i = 0; i < 80; i++) sb.append('x');
|
||
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||
sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("trailing repeat after long preamble: detects only the looping suffix")
|
||
void detectsLoopAfterPreamble() {
|
||
// Realistic: model produces a long valid answer, then enters a
|
||
// loop appending the same trailer. The detector must catch the
|
||
// loop even though the buffer prefix has perfectly varied text.
|
||
StringBuilder sb = new StringBuilder();
|
||
sb.append("好的,我已经为您完成了任务,下面是详细的执行结果:\n");
|
||
sb.append("第一步,我读取了配置文件并解析了内容。\n");
|
||
sb.append("第二步,我调用了天气查询接口拿到了原始数据。\n");
|
||
sb.append("第三步,我将结果格式化为人类可读的中文文本。\n");
|
||
// Now the model gets stuck repeating a closing phrase.
|
||
String trailer = "如有其他问题,请随时告诉我,我会尽快为您解答和处理。\n";
|
||
for (int i = 0; i < 5; i++) sb.append(trailer);
|
||
assertTrue(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||
sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES),
|
||
"trailing 5×-repeated trailer must trip even after long preamble");
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("single-char fill (200x 'a') does NOT trip — too short to be a real period")
|
||
void singleCharFillDoesNotTrip() {
|
||
// 'aaaa...' could be parsed as period=1 with 200 occurrences,
|
||
// but our floor is MIN_PERIOD=24, so a literal 24-char run of
|
||
// 'a' would need to repeat 4× — which is just one continuous
|
||
// run of 96 'a' chars. That's a degenerate case; mark as
|
||
// not-tripping-via-this-detector since it's not the "self-
|
||
// arguing loop" failure mode (a model emitting 'aaaaaaa...'
|
||
// would hit max_tokens harmlessly without any degradation
|
||
// worth user attention).
|
||
StringBuilder sb = new StringBuilder();
|
||
for (int i = 0; i < 200; i++) sb.append('a');
|
||
// 200 'a' chars: period=24 unit is "aaaa...a" (24 of them).
|
||
// The prior 24-char block is also "aaa...a" (24 of them).
|
||
// So they DO match. This trips. Document the behavior — it's
|
||
// mostly harmless because models don't actually loop on single
|
||
// chars.
|
||
assertTrue(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||
sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES),
|
||
"documented behavior: pure single-char fills DO trip; not a real failure mode in practice");
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("invalid args return false defensively")
|
||
void invalidArgsReturnFalse() {
|
||
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix("text", 0, 100, 4));
|
||
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix("text", 24, 240, 1));
|
||
// maxPeriod < minPeriod
|
||
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix("text", 100, 50, 4));
|
||
}
|
||
|
||
// ===== dedupTrailingRepeats =====
|
||
//
|
||
// Once the loop guard fires, the streamed text has already gone out
|
||
// (SSE chunks can't be unsent), but the DB-persisted final answer +
|
||
// IM channel reply should show ONE clean copy of the looping unit
|
||
// instead of the wall the user just watched scroll by.
|
||
|
||
@Test
|
||
@DisplayName("dedup: 5 verbatim copies → 1 copy")
|
||
void dedupCollapsesRepeats() {
|
||
String unit = "收到语音啦!想查昨天的天气没问题,告诉我城市名我马上帮你查!\n";
|
||
StringBuilder sb = new StringBuilder();
|
||
for (int i = 0; i < 5; i++) sb.append(unit);
|
||
String result = NodeStreamingChatHelper.dedupTrailingRepeats(sb.toString(), MIN_PERIOD, MAX_PERIOD);
|
||
assertEquals(unit, result, "5 copies should collapse to exactly 1");
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("dedup: prefix + repeated trailer → prefix + 1 copy of trailer")
|
||
void dedupPreservesPrefixCollapseTrailer() {
|
||
String prefix = "好的,下面是详细回答:第一步完成了。第二步也完成了。下面是结论。\n";
|
||
String trailer = "如有其他问题请随时告诉我,我会尽快为您解答处理。\n";
|
||
StringBuilder sb = new StringBuilder(prefix);
|
||
for (int i = 0; i < 5; i++) sb.append(trailer);
|
||
String result = NodeStreamingChatHelper.dedupTrailingRepeats(sb.toString(), MIN_PERIOD, MAX_PERIOD);
|
||
assertEquals(prefix + trailer, result,
|
||
"prefix preserved verbatim; trailer collapses 5×→1×");
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("dedup: no trailing repeats → buffer unchanged")
|
||
void dedupNoRepeatsUnchanged() {
|
||
String prose = "这是一段没有任何尾部重复的正常回答,包含多个不同的句子和话题。"
|
||
+ "我们讨论了天气、新闻、技术,每段内容都不同。";
|
||
assertEquals(prose,
|
||
NodeStreamingChatHelper.dedupTrailingRepeats(prose, MIN_PERIOD, MAX_PERIOD));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("dedup: only 1 copy at end (no actual repetition) → unchanged")
|
||
void dedupSingleCopyUnchanged() {
|
||
String unit = "请告诉我您所在的城市,我帮您查询。";
|
||
// Just one copy at the tail — nothing to collapse.
|
||
assertEquals(unit,
|
||
NodeStreamingChatHelper.dedupTrailingRepeats(unit, MIN_PERIOD, MAX_PERIOD));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("dedup: empty / null inputs return as-is")
|
||
void dedupEmptyOrNull() {
|
||
assertNull(NodeStreamingChatHelper.dedupTrailingRepeats(null, MIN_PERIOD, MAX_PERIOD));
|
||
assertEquals("", NodeStreamingChatHelper.dedupTrailingRepeats("", MIN_PERIOD, MAX_PERIOD));
|
||
}
|
||
|
||
@Test
|
||
@DisplayName("dedup: 2 copies (the minimum trip threshold) → 1 copy")
|
||
void dedupTwoCopiesCollapse() {
|
||
// dedup uses 2+ copies as its trigger (vs. hasRepeatingSuffix's 4×
|
||
// detection threshold). Once the guard has decided the buffer is
|
||
// looping, even a 2× tail should be collapsed since we know
|
||
// structurally the model is mid-loop.
|
||
String unit = "如果您还有任何其他疑问,欢迎随时联系我,我会尽快回复。";
|
||
String input = unit + unit;
|
||
assertEquals(unit,
|
||
NodeStreamingChatHelper.dedupTrailingRepeats(input, MIN_PERIOD, MAX_PERIOD));
|
||
}
|
||
}
|