//! 上下文管理纯函数与数据类型 — 从 context.rs 抽离的无 `self` 依赖部分 //! //! 职责: //! - Token 粗估器(字符级近似,无 tokenizer 依赖) //! - 上下文窗口配置与预算计算 //! - 消息分组(淘汰时保持工具调用三元组原子性)的数据类型与分类纯函数 //! - 淘汰单元 / 跟踪消息条目等数据类型 //! //! 抽离原因:context.rs 的 `ContextManager` impl 块不可跨文件,但其中依赖的 //! 纯函数 / 数据类型 / 常量无 `self` 依赖,可独立成模块供 context.rs `use` 复用, //! 降低单文件体积。零行为变化:仅搬迁,逻辑原样保留。 //! //! context.rs 通过 `pub use` 重导出本模块的 `TokenEstimator` / `ContextConfig` 等, //! 保持 `df_ai::context::TokenEstimator` 等历史路径对外可见(零调用方变更)。 use serde::{Deserialize, Serialize}; use crate::provider::{ChatMessage, MessageRole}; // ============================================================ // Token 估算器(零依赖粗估) // ============================================================ /// Token 粗估器 — 字符级近似计数,无 tokenizer 依赖 /// /// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。 #[derive(Debug, Clone)] pub struct TokenEstimator { /// 字符 → token 转换系数(默认 0.35,即 ~2.8 字符/token,中英混合偏保守) pub chars_ratio: f32, /// 每条消息固定开销(role 标记 + 格式) pub per_message_overhead: u32, /// 每个 tool_call 的额外开销(name + arguments JSON 结构) pub per_tool_call_overhead: u32, } impl Default for TokenEstimator { fn default() -> Self { Self { chars_ratio: 0.35, per_message_overhead: 4, per_tool_call_overhead: 30, } } } impl TokenEstimator { /// 估算单条消息的 token 数(保守估计) /// /// F-260614-05 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。 /// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致 /// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。 /// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。 pub fn estimate_message(&self, msg: &ChatMessage) -> u32 { let mut char_count = msg.content.chars().count(); if let Some(parts) = &msg.parts { for p in parts { match p { crate::provider::ContentPart::Text { text } => char_count += text.chars().count(), crate::provider::ContentPart::Image { base64, url, .. } => { // CR-260618-11#2:0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。 // 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。 // base64 优先(多模态主载荷),url 次之;url 模式无字节,仅按 URL 长度估 if let Some(b) = base64 { char_count += b.chars().count(); } else if let Some(u) = url { char_count += u.chars().count(); } } } } } let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32; let mut total = content_tokens + self.per_message_overhead; // tool_calls 的 JSON 结构开销(role=Assistant 时可能有) if let Some(ref calls) = msg.tool_calls { for call in calls { total += self.per_tool_call_overhead; total += (call.function.name.chars().count() as f32 * self.chars_ratio).ceil() as u32; total += (call.function.arguments.chars().count() as f32 * self.chars_ratio).ceil() as u32; } } // tool_call_id 开销(role=Tool 时有) if msg.tool_call_id.is_some() { total += 3; } total } /// 估算纯文本字符串的 token 数(用于 system prompt) pub fn estimate_text(&self, text: &str) -> u32 { (text.chars().count() as f32 * self.chars_ratio).ceil() as u32 } } // ============================================================ // 上下文窗口配置 // ============================================================ /// 上下文窗口配置 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContextConfig { /// 窗口上限 token 数(默认 128_000) pub max_tokens: u32, /// 输出预留 token 数(窗口中留给模型生成的部分,默认 8_192) pub output_reserve: u32, /// 安全系数 0.0~1.0(默认 0.85,留 15% 余量) pub safety_ratio: f32, } impl Default for ContextConfig { fn default() -> Self { Self { max_tokens: 128_000, output_reserve: 8_192, safety_ratio: 0.85, } } } impl ContextConfig { /// 预算上限 = (max_tokens - output_reserve) × safety_ratio pub fn budget_limit(&self) -> u32 { (self.max_tokens.saturating_sub(self.output_reserve) as f32 * self.safety_ratio) as u32 } } // ============================================================ // 消息分组(淘汰时保持工具调用三元组原子性) // ============================================================ /// 消息在逻辑上的分组标签,用于淘汰时保持原子性 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MessageGroup { /// 普通 User / Assistant 文本消息(可独立淘汰) Standalone, /// Assistant 带 tool_calls,是三元组的头 ToolCallHead, /// Tool 结果消息,是三元组的尾 ToolResultTail, } /// 带有 token 缓存和分组信息的消息条目 /// /// 字段 `pub`:供阶段2 IPC 经 `ContextManager::messages_mut()` 拿到可变切片后, /// 直接改 `message.status` / 读 `token_count` 做 token 重算(Mutex 单线程访问, /// 同 crate 内安全)。结构体本身也 `pub`(返回类型对外可见)。 pub struct TrackedMessage { pub message: ChatMessage, pub token_count: u32, pub group: MessageGroup, /// [P2 改进5] 该消息所属主题标签(意图识别推断,None=未标记)。当前不参与裁剪/压缩, /// 仅供主题切换检测。向前兼容:TrackedMessage 不 Serialize(只 ChatMessage 落库),无 DB 影响。 pub topic: Option, } /// 按消息角色与 tool_calls 判定其逻辑分组(淘汰单元的原子性基础) /// /// 纯函数:无 `self` 依赖,仅依据 `msg.role` 与 `msg.tool_calls` 分类。 pub fn classify_group(msg: &ChatMessage) -> MessageGroup { match msg.role { MessageRole::Tool => MessageGroup::ToolResultTail, MessageRole::Assistant => { if msg.tool_calls.as_ref().is_some_and(|c| !c.is_empty()) { MessageGroup::ToolCallHead } else { MessageGroup::Standalone } } _ => MessageGroup::Standalone, } } // ============================================================ // 改进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 = 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 { let mut out: Vec = 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 = 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; /// extract_key_info JSON 数组截断上限(防 tool_result 数组过大撑爆 prompt)。 pub const TOOL_RESULT_MAX_ARRAY: usize = 10; /// extract_key_info 单行/少行内容字符截断上限(实测 53/94 次压缩零效果根因: /// 单行 JSON 或 ≤10 行文本绕过行级截断)。超过此值的单行内容将被截断保留头尾。 pub const TOOL_RESULT_CHAR_LIMIT: usize = 1_024; /// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断, /// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。 pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512; /// 判断 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 { // JSON 感知压缩:识别对象中的大数组/大字符串并截断 if let Ok(mut val) = serde_json::from_str::(content) { if let Some(obj) = val.as_object_mut() { let mut truncated = false; for (_key, field) in obj.iter_mut() { // 数组截断 if let Some(arr) = field.as_array() { if arr.len() > TOOL_RESULT_MAX_ARRAY { *field = serde_json::Value::Array( arr.iter().take(TOOL_RESULT_MAX_ARRAY).cloned().collect() ); truncated = true; } } // 字符串字段:先按行数截断,若不足再按字符数截断 if let Some(s) = field.as_str() { let lines: Vec<&str> = s.lines().collect(); let kept = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES; if lines.len() > kept { let mut out: Vec<&str> = Vec::new(); out.extend_from_slice(&lines[..TOOL_RESULT_HEAD_LINES]); out.push("... (压缩中间内容) ..."); out.extend_from_slice(&lines[lines.len()-TOOL_RESULT_TAIL_LINES..]); *field = serde_json::Value::String(out.join("\n")); truncated = true; } else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX { // BUG-260628-01:单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。 // 按字符数截断保留头尾,保证压缩至少生效。 let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect(); let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect(); *field = serde_json::Value::String(format!( "{}...(截断,原始 {} 字符)...{}", head, s.chars().count(), tail )); truncated = true; } } } if truncated { obj.insert("_truncated".into(), serde_json::Value::Bool(true)); return serde_json::to_string(&val).unwrap_or_else(|_| content.to_string()); } } // JSON 解析成功但无需截断 → 原样返回 return content.to_string(); } // 非 JSON 纯文本:按行数截断 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 { // BUG-260628-01:行数少但内容超大的情况(单行 50KB),行级截断无效。 // 按字符数截断保证压缩至少生效。 let char_count = content.chars().count(); if char_count > TOOL_RESULT_CHAR_LIMIT { let head: String = content.chars().take(TOOL_RESULT_CHAR_LIMIT / 2).collect(); let tail: String = content.chars().skip(char_count.saturating_sub(TOOL_RESULT_CHAR_LIMIT / 2)).collect(); return format!( "[工具 {} 输出已压缩: 保留首尾, 原始 {} 字符]\n{}...(截断)...{}", tool_name, char_count, head, tail ); } 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 = 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 } // ============================================================ // 淘汰单元 // ============================================================ /// 淘汰单元:连续消息范围 [..end) + token 总和 /// /// `pub` 供 `build_eviction_units` 的返回类型对外可见(阶段2/3 调用方读 `end` / `token_sum`)。 pub struct EvictionUnit { pub end: usize, pub token_sum: u32, } // ============================================================ // 常量 // ============================================================ /// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次) 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_"; /// 阶段2(path_auth 审批链重构):占位配对完整性(解 400 orphan)常量开关。 /// /// 根因:审批挂起占位 tool_result(内容为 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER) /// 与其 tool_call 头经 sanitize/compress 裁剪后丢配对头 → orphan tool_result(无头)→ /// deepseek-v4-pro 等端点 400。本开关控制 sanitize step3.5(反向 orphan 丢弃) + /// 发送视图出口断言占位配对完整(失败降级 TOOL_MISSING_PREFIX 自愈)。 /// /// true(默认):启用反向 orphan 检测 + 出口占位配对断言自愈(根治 400)。 /// false(回退):sanitize 仅做原正向 orphan(头无 result)处理,出口不断言(旧行为)。 /// 兜底:sanitize view-only 不改持久化,失败只影响单请求,flag 关→行为完全等价改动前。 pub const PLACEHOLDER_INTEGRITY_ENABLED: bool = true; /// 占位 tool_result 内容中嵌入的唯一标记前缀(供 sanitize 识别"审批挂起占位,不可裁")。 /// /// 写入处(audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)格式:`{占位文本}{__PENDING__}{tc_id}`, /// 如 "需要用户审批,等待确认__PENDING__:call_abc123"。读取处(sanitize/出口断言)按此前缀 /// 切出 tc_id,据此把占位 tool_result 与其 tool_call 头强绑定:若头被裁/丢了,降级自愈 /// (头补 TOOL_MISSING_PREFIX 或 tool_result 丢弃),防 orphan 触发 provider 400。 /// /// 设计权衡:不用独立字段(改 ChatMessage schema 跨 crate + DB 迁移成本高),而是 content /// 内嵌标记——占位文本固定且唯一(非用户内容),内嵌不污染语义(LLM 看到也理解"等待审批")。 pub const PENDING_MARKER_PREFIX: &str = "__PENDING__:"; /// 判定 tool_result content 是否为审批挂起占位(含 PENDING_MARKER_PREFIX 标记)。 /// /// 兼容老占位(无标记,纯 PENDING_APPROVAL_PLACEHOLDER 文本)与新占位(带 __PENDING__:tc_id): /// - 新占位:content 含 PENDING_MARKER_PREFIX(`__PENDING__:` 是独占信号,权威判定) /// - 老占位:content == PENDING_APPROVAL_PLACEHOLDER(向前兼容,迁移期共存) /// /// **收紧(误判防护)**:`__PENDING__` 标记是 audit/cache.rs 占位模板独占字符串,绝不会出现在 /// 用户正文里 → 标记存在即权威。老占位分支原用 `starts_with("需要用户审批")` 过松:若用户真实 /// tool_result(如读到的文档片段)恰好以"需要用户审批..."开头,会被误判占位 → sanitize 豁免保留 /// 或 compress 跳过压缩,污染 LLM 上下文。收紧为**精确等于**老占位全文(与 audit/cache.rs /// `PENDING_APPROVAL_PLACEHOLDER = "需要用户审批,等待确认"` 字面量同步),杜绝前缀误伤。 /// /// 注:老占位字面量在 src-tauri audit/cache.rs 定义(pub(crate) 不可跨 crate 引用), /// 此处内联同步字面量并加测试锁定;改字面量须同步 audit/cache.rs 与下方单测。 pub fn is_pending_placeholder(content: &str) -> bool { // 权威信号:`__PENDING__` 标记独占,存在即占位。 if content.contains(PENDING_MARKER_PREFIX) { return true; } // 收紧:老占位精确全文匹配(非 starts_with 前缀),杜绝用户内容前缀误伤。 content == LEGACY_PENDING_PLACEHOLDER_TEXT } /// 老占位(无 __PENDING__ 标记)的精确全文,与 audit/cache.rs `PENDING_APPROVAL_PLACEHOLDER` 同步。 /// /// 单独常量便于:① 字面量漂移自检(改字面量搜此常量即定位所有引用);② 测试锁定。 /// 独立于 PENDING_MARKER_PREFIX(新占位用),老占位是迁移期共存数据。 pub const LEGACY_PENDING_PLACEHOLDER_TEXT: &str = "需要用户审批,等待确认"; /// 从占位 tool_result content 切出嵌入的 tc_id(供 sanitize 反向定位其 tool_call 头)。 /// /// content 形如 "...__PENDING__:call_abc123",切出 "call_abc123"。无标记 → None(老占位 /// 或非占位,调用方按无 tc_id 处理:走常规反向 orphan 检测,占位无标记时不享受强绑定保护)。 pub fn extract_pending_tc_id(content: &str) -> Option<&str> { content .split_once(PENDING_MARKER_PREFIX) .map(|(_, id)| id.trim()) .filter(|id| !id.is_empty()) } #[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 = (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 = (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 = (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 = (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_short_no_newline_unchanged() { // 边界(无换行):单行短内容(字符数 <= CHAR_LIMIT)→ 原样返回 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_compressed() { // BUG-260628-01:单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。 let content = "x".repeat(50_000); let result = extract_key_info(&content, "read_file"); assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len()); assert!(result.contains("已压缩"), "应含压缩标记"); assert!(result.starts_with("[工具 read_file"), "应以工具名开头"); assert!(result.contains("原始 50000 字符"), "应报告原始字符数"); assert!(result.contains("(截断)"), "应含截断标记"); } #[test] fn extract_key_info_json_huge_string_field_truncated() { // BUG-260628-01:JSON 对象中大字符串字段(单行少行)逃逸压缩。 // 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。 let large = "z".repeat(10_000); let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large); let result = extract_key_info(&content, "read_file"); assert!(result.len() < content.len(), "JSON 大字符串字段应压缩: {} >= {}", result.len(), content.len()); assert!(result.contains("_truncated"), "应含 _truncated 标记"); assert!(result.contains("src/main.rs"), "应保留 path 字段"); assert!(result.contains("(截断)"), "应含截断标记"); } #[test] fn extract_key_info_exactly_head_tail_boundary_no_compression() { // 边界:行数 == HEAD+TAIL(10)→ total <= kept_boundary → 原样返回(不压缩) let lines: Vec = (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 = (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 = (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())); } // ── 阶段2 占位配对完整性:is_pending_placeholder / extract_pending_tc_id ── #[test] fn is_pending_placeholder_new_with_marker() { // 新占位:占位文本 + __PENDING__:tc_id 标记 let content = "需要用户审批,等待确认__PENDING__:call_abc123"; assert!(is_pending_placeholder(content), "带标记的新占位应识别"); } #[test] fn is_pending_placeholder_legacy_without_marker() { // 老占位:纯文本无标记(向前兼容,迁移期共存) assert!(is_pending_placeholder("需要用户审批,等待确认"), "老占位(纯文本)应识别"); } #[test] fn is_pending_placeholder_rejects_normal_content() { // 非占位的正常 tool_result 内容不应误判 assert!(!is_pending_placeholder("文件内容: hello world")); assert!(!is_pending_placeholder("{\"ok\":true}")); assert!(!is_pending_placeholder("")); } #[test] fn is_pending_placeholder_rejects_user_content_with_prefix() { // 收紧(防误伤):用户真实 tool_result 内容恰好以"需要用户审批"开头(如读到的文档片段), // 但不是完整老占位全文 → 不应误判为占位(否则 sanitize 豁免保留/compress 跳过压缩污染 LLM)。 assert!( !is_pending_placeholder("需要用户审批的文件清单如下:\n1. 文档A\n2. 文档B"), "以占位前缀开头但非完整老占位的用户内容,不应误判占位" ); assert!( !is_pending_placeholder("需要用户审批一下这个改动,我看不太懂"), "前缀 + 后续不同文本不应误判" ); // 仅完整老占位全文才匹配(精确等值,非前缀) assert!( is_pending_placeholder(LEGACY_PENDING_PLACEHOLDER_TEXT), "完整老占位全文应识别" ); } #[test] fn is_pending_placeholder_marker_is_authoritative_signal() { // 对抗:__PENDING__: 标记是 audit/cache.rs 占位模板独占字符串,绝不会出现在用户正文里。 // 故标记存在即权威信号(即使非开头也认占位)——独占语义保证不误判。 assert!( is_pending_placeholder("任意内容__PENDING__:call_x"), "__PENDING__ 标记是独占信号,存在即识别为占位" ); // 反向:无标记 + 非占位文本开头 → 不识别 assert!( !is_pending_placeholder("用户问:这个需要审批吗?"), "无标记 + 非占位模板开头不应识别" ); } #[test] fn extract_pending_tc_id_new_marker() { // 新占位:切出 __PENDING__: 之后的 tc_id let content = "需要用户审批,等待确认__PENDING__:call_xyz789"; assert_eq!(extract_pending_tc_id(content), Some("call_xyz789")); } #[test] fn extract_pending_tc_id_legacy_no_marker() { // 老占位:无标记 → None assert_eq!(extract_pending_tc_id("需要用户审批,等待确认"), None); } #[test] fn extract_pending_tc_id_empty_id() { // 标记后无内容(异常)→ None(防空 tc_id 误判) assert_eq!(extract_pending_tc_id("需要用户审批,等待确认__PENDING__:"), None); assert_eq!(extract_pending_tc_id("需要用户审批,等待确认__PENDING__: "), None); } #[test] fn extract_pending_tc_id_non_placeholder() { // 非占位内容 → None assert_eq!(extract_pending_tc_id("文件内容"), None); assert_eq!(extract_pending_tc_id(""), None); } }