Files
DevFlow/crates/df-ai/src/context_helpers.rs
绝尘 53e1c1da77 重构: 对话透明化 L1 + ③.2 审批拆分 + ⑥.4 @展开 + DAG 展示
修复 tool_result 压缩零效果(BUG-260628-01):单行/少行/JSON 大字符
串字段逃逸压缩的问题,基于实测数据(53/94 次迭代零节省)调参,
增加字符级保底截断,压缩有效率从 8% 提升至 ~85%+
2026-06-28 03:31:40 +08:00

1164 lines
51 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 上下文管理纯函数与数据类型 — 从 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<String>,
}
/// 按消息角色与 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<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;
/// 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::<serde_json::Value>(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<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
}
// ============================================================
// 淘汰单元
// ============================================================
/// 淘汰单元:连续消息范围 [..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<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_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<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()));
}
// ── 阶段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);
}
}