新增: 消息级溯源 P2 切 ai_messages + AI Chat 跑题改进 P0-P2 + 标题诊断 + 苛刻测

消息级溯源 P2(一次性切读 b + 备份保留):
- 批次A 读路径切 ai_messages(state.rs AppState.ai_messages + 映射 ChatMessage↔AiMessageRecord +
  switch/export load 切 + fallback 兜底)
- 批次B 写路径切 ai_messages(replace_conversation 单事务全量重写 + save/clear 切 + 旧 messages 备份)
- 摘要/压缩不改 updated_at(save_conversation touch_updated_at,compress false 防时间分组跳变)

AI Chat 跑题改进 P0-P2(5 改进,治跑题 5 根因,三原则优雅/可靠/易迭代):
- P0 系统提示聚焦(## 聚焦准则独立段)+ 意图接入 loop(filter_tool_defs 收敛工具 29→5-10,三重 fallback)
- P1 压缩增强(compress prompt 主题锚点 + 失败关键词兜底 extract_keyword_summary)+
  工具结果压缩(should_summarize + extract_key_info view-only 不改持久化)
- P2 主题检测(TrackedMessage.topic + 双高置信保守标记 + tokenize 2-gram 修复中文锚点)
- title LLM complete 失败诊断日志(助定位返空根因)
- 跑题 P0 审查🟡修复(Debug 加 Data 域 + threshold 注释)

苛刻测 38 条(边界/对抗:全漂移/全停用词/全错误行/2KB 边界/连续主题切换/中文 2-gram/emoji)
df-ai 227 passed + workspace EXIT 0
This commit is contained in:
2026-06-20 03:54:58 +08:00
parent 4e3a11f925
commit de049704c6
11 changed files with 2434 additions and 44 deletions

View File

@@ -152,6 +152,9 @@ pub struct TrackedMessage {
pub message: ChatMessage,
pub token_count: u32,
pub group: MessageGroup,
/// [P2 改进5] 该消息所属主题标签(意图识别推断,None=未标记)。当前不参与裁剪/压缩,
/// 仅供主题切换检测。向前兼容:TrackedMessage 不 Serialize(只 ChatMessage 落库),无 DB 影响。
pub topic: Option<String>,
}
/// 按消息角色与 tool_calls 判定其逻辑分组(淘汰单元的原子性基础)
@@ -171,6 +174,283 @@ pub fn classify_group(msg: &ChatMessage) -> MessageGroup {
}
}
// ============================================================
// 改进3 B: 压缩失败兜底 — 关键词摘要提取(纯函数,无 LLM)
// ============================================================
/// 压缩失败降级:从消息提取关键词摘要(避免裸裁剪丢主题)。
///
/// 当 LLM 压缩失败时,原本只能裸裁剪保最近 6 条(可能丢失用户反复提到的核心
/// 主题词/实体名)。本函数从 user 消息中提取频次最高的关键词,组装成 system 摘要
/// 塞回首条,作为续接锚点。
///
/// 策略(纯启发式,无 tokenizer 依赖):
/// - 仅取 user 消息(用户表达意图的载体,assistant 多为操作流水)。
/// - 按非字母数字下划线汉字边界分词;保留长度 >= 2 的词。
/// - 去停用词(常见虚词/助词,中英混合)。
/// - 频次 top-N,频次相同按词长降序(长实体优先),再按字典序稳定。
/// - 返回 system 文本(空消息/无可提取词 → 空字符串,调用方判空跳过 insert)。
pub fn extract_keyword_summary(msgs: &[ChatMessage]) -> String {
const TOP_N: usize = 10;
const MIN_WORD_LEN: usize = 2;
// 中英混合停用词表(高频虚词/助词/常见无主题意义词)。
const STOPWORDS: &[&str] = &[
// 中文
"", "", "", "", "", "", "", "", "", "", "", "", "一个",
"", "", "", "", "", "", "", "", "", "", "没有", "", "",
"自己", "", "", "这个", "那个", "什么", "怎么", "可以", "应该", "需要",
"已经", "现在", "然后", "因为", "所以", "但是", "如果", "虽然", "一下", "一些",
// 英文
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "to", "of",
"in", "on", "at", "by", "for", "with", "about", "as", "into", "like", "through",
"after", "over", "between", "and", "but", "or", "not", "no", "yes", "if", "then",
"this", "that", "these", "those", "it", "its", "i", "you", "he", "she", "we",
"they", "them", "my", "your", "his", "her", "our", "their", "do", "does", "did",
"have", "has", "had", "will", "would", "can", "could", "should", "may", "might",
"please", "help", "want", "need", "make", "use", "get",
];
use std::collections::HashMap;
let mut freq: HashMap<String, u32> = HashMap::new();
for msg in msgs {
// 仅 user 消息参与(assistant/tool 多为操作流水,无主题价值)。
if !matches!(msg.role, MessageRole::User) {
continue;
}
for token in tokenize(msg.content.as_str()) {
let word = token.trim();
if word.chars().count() < MIN_WORD_LEN {
continue;
}
if STOPWORDS.contains(&word) {
continue;
}
*freq.entry(word.to_string()).or_insert(0) += 1;
}
}
if freq.is_empty() {
return String::new();
}
let mut entries: Vec<(String, u32)> = freq.into_iter().collect();
// 排序:频次降序 → 词长降序(长实体优先) → 字典序稳定。
entries.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| b.0.chars().count().cmp(&a.0.chars().count()))
.then_with(|| a.0.cmp(&b.0))
});
let keywords: Vec<&str> = entries.iter().take(TOP_N).map(|(w, _)| w.as_str()).collect();
if keywords.is_empty() {
return String::new();
}
format!(
"[上下文压缩失败兜底摘要] 用户在历史对话中反复提及的关键主题词(作为续接锚点保留):\n{}",
keywords.join("")
)
}
/// 简单分词:按非(字母/数字/下划线/汉字)边界切分,保留连续的词素 + 汉字 2-gram。
///
/// 中英混合:连续的拉丁/数字/下划线聚成一个词素(标识符如 build_for_request);
/// 汉字既作为单字词素,又额外产出 2-gram 相邻汉字词素(如"压缩架构"→"压缩"/"缩架"/"架构"),
/// 以捕获 2 字常用词(CR-26 🟡1:此前单字全被 MIN_WORD_LEN=2 滤掉,致"压缩/架构/审批"
/// 这类高频 2 字词在关键词摘要中丢失)。纯启发式,不依赖 jieba(零依赖原则)。
fn tokenize(s: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut buf = String::new();
let mut prev_class: u8 = 0; // 0=none,1=han,2=word(字母数字_),3=other
let mut prev_han: Option<char> = None; // [CR-26] 上一个汉字,供 2-gram 滑窗
for ch in s.chars() {
let class = if ch.is_alphanumeric() && !is_han(ch) {
2
} else if is_han(ch) {
1
} else if ch == '_' {
2
} else {
3
};
if class == 3 {
if !buf.is_empty() {
out.push(std::mem::take(&mut buf));
}
prev_class = 0;
prev_han = None; // 非汉字边界重置 2-gram 窗口(不跨边界组词)
continue;
}
// 词素切换:汉字单独成词(每个汉字一个词素,便于统计高频字组),
// 但连续拉丁/下划线/数字聚成一个词素(标识符如 build_for_request)。
if class == 1 {
// 汉字:先冲掉当前 buf(若有),再把单字作为独立词素。
if !buf.is_empty() {
out.push(std::mem::take(&mut buf));
}
out.push(ch.to_string());
// [CR-26 🟡1] 2-gram 汉字滑窗:与前一个汉字(若连续,prev_class==1)组成 2 字词素。
// "压缩架构"→"压缩"/"缩架"/"架构";"压缩 文件"(空格隔开)→"压缩"/"文件",无跨边界 2-gram。
if let Some(prev) = prev_han {
if prev_class == 1 {
let mut gram = String::new();
gram.push(prev);
gram.push(ch);
out.push(gram);
}
}
prev_han = Some(ch);
prev_class = 1;
} else {
// class == 2:拉丁/数字/下划线聚成连续词素。
// 从汉字切换到拉丁或反向时,先冲掉 buf + 重置 2-gram 窗口。
if prev_class == 1 {
buf.clear();
}
prev_han = None; // 离开汉字区,2-gram 窗口重置
buf.push(ch);
prev_class = 2;
}
}
if !buf.is_empty() {
out.push(buf);
}
out
}
/// 判定是否汉字(统一码 CJK 区间,粗略覆盖常用范围)。
fn is_han(ch: char) -> bool {
matches!(ch as u32,
0x4E00..=0x9FFF // CJK 统一汉字
| 0x3400..=0x4DBF // CJK 扩展 A
| 0xF900..=0xFAFF // CJK 兼容
)
}
// ============================================================
// 改进4: 工具结果压缩(view-only 摘要化纯函数)
// ============================================================
/// tool_result 内容触发摘要压缩的大小阈值(字节,~512 行典型输出)。
pub const TOOL_RESULT_SUMMARIZE_BYTES: usize = 2_048;
/// tool_result 占历史 token 比例阈值(占比超此值触发摘要)。
pub const TOOL_RESULT_SUMMARIZE_RATIO: f32 = 0.40;
/// extract_key_info 保留的首部行数。
pub const TOOL_RESULT_HEAD_LINES: usize = 5;
/// extract_key_info 保留的尾部行数。
pub const TOOL_RESULT_TAIL_LINES: usize = 5;
/// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。
///
/// - `content_len`:tool_result content 字节数。
/// - `history_tokens`:当前历史总 token(用于占比判定)。
/// - `content_tokens`:该 tool_result 自身的 token 估算(供占比)。
///
/// 任一阈值触发即压缩。0 history/0 content 不触发(避免除零/空消息误压)。
pub fn should_summarize_tool_result(
content_len: usize,
history_tokens: u32,
content_tokens: u32,
) -> bool {
if content_len >= TOOL_RESULT_SUMMARIZE_BYTES {
return true;
}
if history_tokens == 0 {
return false;
}
let ratio = content_tokens as f32 / history_tokens as f32;
ratio > TOOL_RESULT_SUMMARIZE_RATIO
}
/// 提取 tool_result 关键信息(纯函数,无 LLM):保留错误行 + 首/尾各若干行,中间省略。
///
/// 策略:
/// - 全文扫一遍,挑出含错误信号的行(`error`/`Error`/`panic`/`失败`/`.rs:N` 文件定位行)。
/// - 保留首 `TOOL_RESULT_HEAD_LINES` 行 + 末 `TOOL_RESULT_TAIL_LINES` 行(上下文边界)。
/// - 错误行(在头尾区间外的)额外插入,标注位置。
/// - 中间大段省略为 `... (省略 N 行) ...`。
///
/// `tool_name` 仅用于摘要头注释,不参与内容判断。空 content 返回空字符串。
pub fn extract_key_info(content: &str, tool_name: &str) -> String {
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() {
return String::new();
}
// 短内容不压缩(行数不足以省略)。
let total = lines.len();
let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
if total <= kept_boundary {
return content.to_string();
}
let head_end = TOOL_RESULT_HEAD_LINES;
let tail_start = total.saturating_sub(TOOL_RESULT_TAIL_LINES);
let mut out: Vec<String> = Vec::new();
out.push(format!("[工具 {} 输出已压缩: 保留首尾 + 错误行, 共 {} 行]", tool_name, total));
// 头部。
for line in &lines[..head_end] {
out.push((*line).to_string());
}
// 中部:扫描错误行(head_end..tail_start 区间内)。
let mut error_lines: Vec<(usize, &str)> = Vec::new();
for (idx, line) in lines.iter().enumerate() {
if idx < head_end || idx >= tail_start {
continue;
}
if is_error_line(line) {
error_lines.push((idx, *line));
}
}
if error_lines.is_empty() {
// 无错误行:单一省略标记。
let omitted = tail_start - head_end;
out.push(format!("... (省略 {} 行) ...", omitted));
} else {
// 有错误行:按位置分段省略 + 插入错误行。
let mut last = head_end;
for (idx, line) in &error_lines {
if *idx > last {
out.push(format!("... (省略 {} 行) ...", idx - last));
}
out.push(format!("[行 {}] {}", idx + 1, line));
last = idx + 1;
}
if last < tail_start {
out.push(format!("... (省略 {} 行) ...", tail_start - last));
}
}
// 尾部。
for line in &lines[tail_start..] {
out.push((*line).to_string());
}
out.join("\n")
}
/// 判定一行是否错误信号行(错误信息/panic/失败/源码定位)。
fn is_error_line(line: &str) -> bool {
let lower = line.to_lowercase();
if lower.contains("error") || lower.contains("panic") {
return true;
}
if line.contains("失败") || line.contains("错误") || line.contains("异常") {
return true;
}
// 源码定位行:常见 `.rs:NN:` / `.rs:NNN` 格式(rust 编译错误/panic 定位)。
if line.contains(".rs:") {
return true;
}
false
}
// ============================================================
// 淘汰单元
// ============================================================
@@ -193,3 +473,460 @@ pub const PROTECT_COUNT: usize = 6;
/// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176
/// 此类 id 必然无匹配 tool_result是历史中毒的标志sanitize 时据此剔除畸形三元组。
pub const TOOL_MISSING_PREFIX: &str = "tool_missing_";
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::ChatMessage;
// ── 改进3 B: extract_keyword_summary ──
#[test]
fn keyword_summary_empty_messages() {
assert_eq!(extract_keyword_summary(&[]), "");
}
#[test]
fn keyword_summary_only_assistant_returns_empty() {
// 非 user 消息不参与提取。
let msgs = vec![
ChatMessage::assistant("build_for_request 的实现"),
ChatMessage::assistant("另一个 assistant 消息"),
];
assert_eq!(extract_keyword_summary(&msgs), "");
}
#[test]
fn keyword_summary_chinese_extracts_frequent_terms() {
let msgs = vec![
ChatMessage::user("帮我看看 build_for_request 的实现"),
ChatMessage::assistant("ok"),
ChatMessage::user("build_for_request 怎么裁剪的"),
ChatMessage::user("build_for_request 这个函数有问题"),
];
let summary = extract_keyword_summary(&msgs);
assert!(summary.contains("build_for_request"), "应提取高频标识符, 实际: {}", summary);
assert!(summary.contains("上下文压缩失败兜底摘要"));
}
#[test]
fn keyword_summary_filters_stopwords() {
// 全是停用词/单字 → 返回空。
let msgs = vec![ChatMessage::user("的 了 是 在 我 有 和")];
assert_eq!(extract_keyword_summary(&msgs), "");
}
#[test]
fn keyword_summary_mixed_cn_en() {
let msgs = vec![
ChatMessage::user("检查 ContextManager 的 history_tokens"),
ChatMessage::user("ContextManager token 超预算"),
];
let summary = extract_keyword_summary(&msgs);
assert!(summary.contains("ContextManager"), "应提取英文标识符, 实际: {}", summary);
// token 频次 1 也应入榜(去停用词后剩余的有意义词)。
}
// ── CR-26 🟡1: tokenize 2-gram 汉字(中文 2 字词不再被滤掉) ──
#[test]
fn tokenize_chinese_2gram_produces_bigram_terms() {
// "压缩架构" 应产出 2-gram 词素 "压缩"/"缩架"/"架构",不再只剩单字。
let toks = tokenize("压缩架构");
// 2-gram 入榜
assert!(toks.contains(&"压缩".to_string()), "应产出 2-gram 压缩, 实际: {:?}", toks);
assert!(toks.contains(&"架构".to_string()), "应产出 2-gram 架构, 实际: {:?}", toks);
// 单字也并存
assert!(toks.contains(&"".to_string()));
assert!(toks.contains(&"".to_string()));
}
#[test]
fn tokenize_chinese_2gram_not_crossing_boundary() {
// 空格/标点隔开 → 不跨边界组 2-gram("压缩 文件"→"压缩"/"文件",无"缩文")。
let toks = tokenize("压缩 文件");
assert!(toks.contains(&"压缩".to_string()));
assert!(toks.contains(&"文件".to_string()));
assert!(!toks.contains(&"缩文".to_string()), "空格边界不应跨字组 2-gram");
}
#[test]
fn tokenize_english_identifier_unchanged() {
// 既有英文标识符不被 2-gram 改造:连续拉丁聚成一个词素。
let toks = tokenize("build_for_request is cool");
assert!(toks.contains(&"build_for_request".to_string()), "标识符应保持完整, 实际: {:?}", toks);
assert!(toks.contains(&"is".to_string()));
assert!(toks.contains(&"cool".to_string()));
}
#[test]
fn keyword_summary_chinese_2gram_terms_in_top() {
// CR-26 🟡1 验证:中文 2 字常用词经 2-gram 入榜,不再被 MIN_WORD_LEN=2 滤掉。
let msgs = vec![
ChatMessage::user("压缩上下文 token 超预算了"),
ChatMessage::user("架构需要重构压缩逻辑"),
];
let summary = extract_keyword_summary(&msgs);
// "压缩"出现两次(2-gram)应入榜;"架构"频次 1 也应有机会。
assert!(
summary.contains("压缩") || summary.contains("架构"),
"中文 2 字词经 2-gram 应入榜, 实际: {}",
summary
);
}
// ── 改进4: should_summarize_tool_result ──
#[test]
fn should_summarize_short_content_below_byte_threshold() {
// 100 字节 + history 充足,占比低 → 不压缩。
assert!(!should_summarize_tool_result(100, 10_000, 35));
}
#[test]
fn should_summarize_large_bytes_triggers() {
// > 2KB 触发。
assert!(should_summarize_tool_result(3_000, 100_000, 100));
}
#[test]
fn should_summarize_high_ratio_triggers() {
// content 占 history 50% > 40% 阈值触发(content_len 未超 2KB)。
assert!(should_summarize_tool_result(1_000, 1_000, 500));
}
#[test]
fn should_summarize_zero_history_no_panic() {
// 0 history + 小 content → 不压缩且不除零 panic。
assert!(!should_summarize_tool_result(100, 0, 50));
}
// ── 改进4: extract_key_info ──
#[test]
fn extract_key_info_short_content_unchanged() {
let content = "line1\nline2\nline3";
assert_eq!(extract_key_info(content, "read_file"), content);
}
#[test]
fn extract_key_info_empty_returns_empty() {
assert_eq!(extract_key_info("", "read_file"), "");
}
#[test]
fn extract_key_info_keeps_head_tail_and_errors() {
let mut lines: Vec<String> = (1..=20).map(|i| format!("line {}", i)).collect();
// 在中间插入一行 rust 错误定位(第 12 行,索引 11)。
lines[11] = "error[E0308]: mismatched types at src/foo.rs:42:13".to_string();
let content = lines.join("\n");
let result = extract_key_info(&content, "cargo_build");
assert!(result.contains("已压缩"), "应含压缩标记, 实际: {}", result);
assert!(result.contains("line 1"), "应保留首行");
assert!(result.contains("line 20"), "应保留末行");
assert!(result.contains("src/foo.rs:42"), "应保留错误行");
assert!(result.contains("行 12"), "错误行应标注原始位置");
assert!(result.contains("省略"), "应含省略标记");
}
#[test]
fn extract_key_info_detects_panic_and_failure() {
let lines: Vec<String> = (1..=15)
.map(|i| {
if i == 8 {
"thread main panicked at lib.rs:10".to_string()
} else if i == 9 {
"执行失败".to_string()
} else {
format!("row {}", i)
}
})
.collect();
let content = lines.join("\n");
let result = extract_key_info(&content, "run_cmd");
assert!(result.contains("panicked"));
assert!(result.contains("lib.rs:10"));
assert!(result.contains("执行失败"));
}
#[test]
fn extract_key_info_no_errors_single_omission() {
// 中间无错误行 → 单一省略标记。
let lines: Vec<String> = (1..=20).map(|i| format!("data {}", i)).collect();
let content = lines.join("\n");
let result = extract_key_info(&content, "list_dir");
// 计算省略标记出现次数:中间 10 行省略,应仅 1 个省略段。
let omission_count = result.matches("省略").count();
assert_eq!(omission_count, 1, "无错误行应单一省略, 实际 {} 次: {}", omission_count, result);
}
// ===== 苛刻测:对抗 + 边界 + 极端 =====
// ── extract_keyword_summary:对抗/极端 ──
#[test]
fn keyword_summary_no_user_messages_returns_empty_no_panic() {
// 极端:全 assistant/tool 消息,无 user → 返空串(不 panic)
let msgs = vec![
ChatMessage::assistant("我读了文件"),
ChatMessage::tool_result("call_1", "{\"ok\":true}"),
ChatMessage::assistant("完成"),
];
assert_eq!(extract_keyword_summary(&msgs), "");
}
#[test]
fn keyword_summary_all_stopwords_returns_empty() {
// 对抗(全停用词):user 消息全是"的/了/是/在/我"(单字 + 停用词双过滤)
// → 全被滤,返空串
let msgs = vec![ChatMessage::user("的 了 是 在 我 有 和 就 不 人 都 一 上")];
assert_eq!(extract_keyword_summary(&msgs), "");
}
#[test]
fn keyword_summary_single_long_message_caps_top10() {
// 极端(单条 10K 字 user):top-10 不超 10,高频词正确入榜。
// 构造 20 个不同高频词各重复 5 次 + 大量填充,验证 take(TOP_N=10) 生效。
let mut content = String::new();
// 20 个候选词,每个写 5 次(词频递减可验排序)
let words: Vec<&str> = (0..20).map(|_| "高频词").collect();
for (i, w) in words.iter().enumerate() {
for _ in 0..5 {
content.push_str(w);
content.push_str(&format!("序号{} ", i)); // 拉丁数字区分不同实例
}
}
// 填充到 ~10K 字
while content.chars().count() < 10_000 {
content.push_str("填充内容x ");
}
let msgs = vec![ChatMessage::user(content)];
let summary = extract_keyword_summary(&msgs);
// 摘要非空 + 关键词数 <= 10(逗号分隔统计)
assert!(summary.contains("上下文压缩失败兜底摘要"));
// 取摘要中关键词行(第二行),按"、"切分数
let kw_line = summary.lines().nth(1).unwrap_or("");
let kw_count = kw_line.split('、').count();
assert!(kw_count <= 10, "top-10 应不超 10, 实际 {} 个: {}", kw_count, kw_line);
assert!(kw_count >= 1, "应至少提取 1 个关键词");
}
#[test]
fn keyword_summary_mixed_cn_en_2gram_and_english_both_ranked() {
// 对抗(中英混合 + 2-gram):"read 压缩架构 file" →
// 中文 2-gram("压缩"/"架构") + 英文("read"/"file") 均能被 tokenize 产出
let toks = tokenize("read 压缩架构 file");
assert!(toks.contains(&"压缩".to_string()), "应产 2-gram 压缩, 实际: {:?}", toks);
assert!(toks.contains(&"架构".to_string()), "应产 2-gram 架构, 实际: {:?}", toks);
assert!(toks.contains(&"read".to_string()));
assert!(toks.contains(&"file".to_string()));
// 经 extract_keyword_summary(去停用词 + MIN_LEN=2):
// - "read"(4 字母)入榜;"file"(4 字母)入榜
// - "压缩"/"架构"(2 字 2-gram)入榜
let msgs = vec![ChatMessage::user("read 压缩架构 file")];
let summary = extract_keyword_summary(&msgs);
assert!(summary.contains("压缩") || summary.contains("架构"), "中文 2-gram 应入榜: {}", summary);
assert!(summary.contains("read"), "英文 read 应入榜: {}", summary);
assert!(summary.contains("file"), "英文 file 应入榜: {}", summary);
}
#[test]
fn keyword_summary_pure_punctuation_emoji_returns_empty() {
// 对抗(纯标点/emoji 无实质词):所有字符非字母数字汉字 → tokenize 返空 → 摘要空
let msgs = vec![ChatMessage::user("🎉,。!?...... 😊 👍")];
assert_eq!(extract_keyword_summary(&msgs), "");
}
// ── should_summarize_tool_result:边界阈值 ──
#[test]
fn should_summarize_exactly_2048_bytes_triggers() {
// 边界:content_len == TOOL_RESULT_SUMMARIZE_BYTES(2048)→ >= 触发(true)
assert!(
should_summarize_tool_result(TOOL_RESULT_SUMMARIZE_BYTES, 100_000, 1),
"刚好 2048 字节(>= 阈值)应触发"
);
}
#[test]
fn should_summarize_just_below_2048_no_trigger_when_low_ratio() {
// 边界:content_len = 2047(< 2048)+ 占比极低 → 不触发
assert!(
!should_summarize_tool_result(TOOL_RESULT_SUMMARIZE_BYTES - 1, 100_000, 1),
"2047 字节 + 低占比不应触发"
);
}
#[test]
fn should_summarize_zero_history_zero_content_no_panic() {
// 极端:history=0 + content_len=0 + content_tokens=0 → 不触发,不除零 panic
assert!(!should_summarize_tool_result(0, 0, 0));
}
#[test]
fn should_summarize_zero_history_below_byte_threshold_no_trigger() {
// 边界:history=0(除零短路返 false)+ content_len < 2048 → 不触发
// (history=0 时占比分支被短路,只看字节阈值)
assert!(!should_summarize_tool_result(500, 0, 999_999), "history=0 时占比分支短路,字节未超不触发");
}
#[test]
fn should_summarize_ratio_exactly_40_percent_no_trigger() {
// 边界(严格 >):占比 == 0.40 不触发(用 > 而非 >=)。
// content_tokens / history_tokens = 40/100 = 0.40,严格大于判定为 false。
// 但 f32 精度:40/100=0.4 精确,0.4 > 0.40 → false。
assert!(
!should_summarize_tool_result(100, 100, 40),
"占比 == 40% 不触发(严格 >), 实际占比 {}",
40.0_f32 / 100.0_f32
);
}
#[test]
fn should_summarize_ratio_just_above_40_percent_triggers() {
// 边界:占比略超 40% → 触发。content_len < 2048 走占比分支。
// 41/100 = 0.41 > 0.40 → true
assert!(
should_summarize_tool_result(100, 100, 41),
"占比 41% > 40% 应触发"
);
}
// ── extract_key_info:对抗(全错误行/单行超长) ──
#[test]
fn extract_key_info_all_error_lines_preserved() {
// 对抗(全错误行):行数 > HEAD+TAIL(11),且每行都含 error → 全保留不省略
let lines: Vec<String> = (1..=15).map(|i| format!("error: failure {}", i)).collect();
let content = lines.join("\n");
let result = extract_key_info(&content, "run_cmd");
// 15 行全含 error,首 5(头) + 末 5(尾)区间外 5 行也全是 error → 全部保留
for i in 1..=15 {
assert!(
result.contains(&format!("failure {}", i)),
"错误行 failure {} 应保留, result: {}",
i,
result
);
}
// 不应出现省略(全错误行被插入,无中间省略段)
assert!(!result.contains("省略"), "全错误行不应省略, result: {}", result);
}
#[test]
fn extract_key_info_single_line_no_newline_unchanged() {
// 边界(无换行):单行(无 \n)→ lines() 返 1 行,total <= kept_boundary → 原样返回
let content = "single line no newline";
assert_eq!(extract_key_info(content, "read_file"), content);
}
#[test]
fn extract_key_info_single_huge_line_no_newline_unchanged() {
// 极端(单行 50KB 无换行):lines() 返 1 行 → 原样返回(不走首尾切分)
let content = "x".repeat(50_000);
let result = extract_key_info(&content, "read_file");
assert_eq!(result, content, "单行无换行应原样返回(即使超长)");
}
#[test]
fn extract_key_info_exactly_head_tail_boundary_no_compression() {
// 边界:行数 == HEAD+TAIL(10)→ total <= kept_boundary → 原样返回(不压缩)
let lines: Vec<String> = (1..=10).map(|i| format!("line {}", i)).collect();
let content = lines.join("\n");
let result = extract_key_info(&content, "read_file");
assert_eq!(result, content, "10 行(== HEAD+TAIL)应原样不压缩");
}
#[test]
fn extract_key_info_eleven_lines_triggers_compression() {
// 边界:行数 == 11(刚超 kept_boundary=10)→ 触发压缩,含标记
let lines: Vec<String> = (1..=11).map(|i| format!("line {}", i)).collect();
let content = lines.join("\n");
let result = extract_key_info(&content, "read_file");
assert!(result.contains("已压缩"), "11 行应触发压缩");
assert!(result.contains("line 1"), "保留首行");
assert!(result.contains("line 11"), "保留末行");
}
#[test]
fn extract_key_info_error_line_outside_boundary_kept_with_index() {
// 边界:错误行恰在头部区间内(idx < head_end)→ 不重复插入(头部已含)
// 错误行在尾部区间内(idx >= tail_start)→ 不重复插入(尾部已含)
// 错误行在中间区间 → 标注 [行 N] 插入
let mut lines: Vec<String> = (1..=20).map(|i| format!("norm {}", i)).collect();
// idx=2(头部区间 [0,5))错误行 → 头部已含,不在 error_lines(扫描跳过 head/tail)
lines[2] = "error in head zone".to_string();
// idx=18(尾部区间 [15,20))错误行 → 尾部已含
lines[18] = "error in tail zone".to_string();
// idx=10(中间)错误行 → 标注插入
lines[10] = "error in middle".to_string();
let content = lines.join("\n");
let result = extract_key_info(&content, "read_file");
// 中间错误行被标注插入(原始行号 11)
assert!(result.contains("[行 11] error in middle"), "中间错误行应标注插入: {}", result);
// 头/尾错误行原样保留(无 [行 N] 标注)
assert!(result.contains("error in head zone"));
assert!(result.contains("error in tail zone"));
}
// ── tokenize:对抗(中文长实体/emoji/全角/混合标点) ──
#[test]
fn tokenize_chinese_long_entity_produces_overlapping_2grams() {
// 对抗(中文长实体):"跨端架构设计"(5 字)→
// 单字:跨/端/架/构/设/计(6 个?不,5 字 5 个单字)
// 2-gram:跨端/端架/架构/构建/设计(5 个,相邻滑窗)
let toks = tokenize("跨端架构设计");
// 单字
for ch in "跨端架构设计".chars() {
assert!(toks.contains(&ch.to_string()), "单字 {} 应产出: {:?}", ch, toks);
}
// 2-gram(相邻滑窗)
assert!(toks.contains(&"跨端".to_string()), "应含 2-gram 跨端: {:?}", toks);
assert!(toks.contains(&"端架".to_string()), "应含 2-gram 端架: {:?}", toks);
assert!(toks.contains(&"架构".to_string()), "应含 2-gram 架构: {:?}", toks);
// 相邻滑窗:构→设 相邻产 2-gram "构设"(非"构建",构建非相邻字)
assert!(toks.contains(&"构设".to_string()), "应含 2-gram 构设(相邻滑窗): {:?}", toks);
assert!(toks.contains(&"设计".to_string()), "应含 2-gram 设计: {:?}", toks);
}
#[test]
fn tokenize_emoji_and_fullwidth_punctuation_excluded() {
// 对抗(emoji/全角标点):🎉//。非字母数字汉字 → class=3 → 不入词素,重置 2-gram 窗口
let toks = tokenize("架构,🎉设计");
// "架构"(,前)、"设计"(🎉后)各成 2-gram;全角逗号/emoji 隔断,无"构设"跨边界 2-gram
assert!(toks.contains(&"架构".to_string()));
assert!(toks.contains(&"设计".to_string()));
assert!(!toks.contains(&"构设".to_string()), "emoji/全角标点应隔断 2-gram");
// emoji 本身不入词素
assert!(!toks.iter().any(|t| t.contains('🎉')), "emoji 不应入词素: {:?}", toks);
}
#[test]
fn tokenize_mixed_punctuation_separates_latin_words() {
// 对抗(混合标点分隔):"a,b,c" → 逗号分隔 → "a"/"b"/"c" 三个独立词素
// (但单字母 a/b/c 因 MIN_WORD_LEN=2 在 summary 阶段被滤,tokenize 层应产出)
let toks = tokenize("a,b,c");
assert!(toks.contains(&"a".to_string()));
assert!(toks.contains(&"b".to_string()));
assert!(toks.contains(&"c".to_string()));
// 不应有 "abc" 或 "a,b" 这种带标点的聚合
assert!(!toks.iter().any(|t| t.contains(',')), "标点不应聚入词素: {:?}", toks);
}
#[test]
fn tokenize_empty_string_returns_empty() {
// 极端:空串 → 空 Vec(不 panic)
let toks = tokenize("");
assert!(toks.is_empty());
}
#[test]
fn tokenize_underscore_keeps_identifier() {
// 边界:下划线属 class=2,标识符 build_for_request 保持完整
let toks = tokenize("call build_for_request now");
assert!(toks.contains(&"build_for_request".to_string()));
assert!(toks.contains(&"call".to_string()));
assert!(toks.contains(&"now".to_string()));
}
}