449 lines
20 KiB
Rust
449 lines
20 KiB
Rust
//! F-15 上下文压缩 — LLM 摘要公共函数
|
|
//!
|
|
//! 阶段1 基础(本文件):仅暴露 `compress_via_llm` 函数,供阶段2 IPC
|
|
//! (`ai_chat_compress_context`)与阶段3 agentic loop 自动压缩共用。
|
|
//!
|
|
//! 模式严格对齐 [`super::title`] 的 `generate_title_via_llm`:非流式
|
|
//! `provider.complete()` + `LlmConcurrency` 双层 Semaphore 限流 + select_model_id
|
|
//! 路由(Lite 智力, 无 tool_use, 标准成本上限)兜底 `default_model`。失败返 `Err`,
|
|
//! 调用方按场景降级(手动压缩报错给用户;自动压缩降级为原裁剪行为)。
|
|
|
|
use df_ai::context_helpers::CompressedSummary;
|
|
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
|
// 压缩路由 — TaskRequirements(Standard 智力即可,无需工具)。压缩是结构化
|
|
// 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。
|
|
// 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。
|
|
use df_ai::router::{
|
|
is_likely_image_model, select_model_id, Modality, TaskRequirements,
|
|
};
|
|
use df_storage::models::AiProviderRecord;
|
|
|
|
use crate::state::LlmConcurrency;
|
|
|
|
use super::prompt::compress_prompt;
|
|
|
|
/// 调 LLM 非流式把 active 消息压缩为四段式摘要。
|
|
///
|
|
/// - 输入:`provider`(经 [`super::secret::build_provider_for`] 构造,含 keyring 解析)
|
|
/// + `provider_config`(取 `default_model` 兜底 + `model_configs` 路由池)
|
|
/// + `active_msgs`(待压缩的 active 消息,来自 [`ContextManager::compress_old_messages`]
|
|
/// 的返回或阶段2 IPC 取全段 active)
|
|
/// + `lang`("zh"/"en",与系统提示词语言一致,影响 `compress_prompt` 选模板)
|
|
/// - 输出:`Result<String, String>` — `Ok` 为 LLM 生成的摘要文本(已 trim);
|
|
/// `Err` 为 provider 调用失败(底层 `anyhow::Error` 转 String,调用方降级不阻塞)。
|
|
///
|
|
/// 模型选择:`select_model_id(TaskRequirements{无 tool_use})`,
|
|
/// 池空/无匹配兜底 `default_model`(与 `generate_title_via_llm` 一致)。
|
|
///
|
|
/// 限流:复用 `LlmConcurrency` 双层 Semaphore(压缩属独立 LLM 调用,纳入全局 + 单对话限流,
|
|
/// 不与主 stream_llm 抢资源失控)。
|
|
///
|
|
/// 注意:本函数只负责"喂 LLM 出摘要文本",不改 ContextManager 状态(标 compressed +
|
|
/// 插摘要 system 由调用方做,职责分离,便于单元测试与多调用点复用)。
|
|
//
|
|
// 调用方:agentic/mod.rs:807(agentic loop 自动压缩)+ commands/chat.rs:1102
|
|
// (ai_chat_compress_context IPC)。
|
|
pub(crate) async fn compress_via_llm(
|
|
provider: &dyn LlmProvider,
|
|
provider_config: &AiProviderRecord,
|
|
active_msgs: Vec<ChatMessage>,
|
|
lang: &str,
|
|
conv_id: &str,
|
|
llm_concurrency: &LlmConcurrency,
|
|
) -> Result<CompressedSummary, String> {
|
|
if active_msgs.is_empty() {
|
|
return Err("无可压缩消息(active 消息为空)".to_string());
|
|
}
|
|
// 阶段2(占位配对完整性):压缩段含未闭合占位(占位 tool_result 无对应 tool_call 头)→
|
|
// 跳过该三元组不压缩,返 Err 让调用方降级(保留原状)。理由:压缩 LLM 摘要可能破坏占位
|
|
// content/__PENDING__ 标记或拆散占位 result 与(已丢的)头的关系,使下游 sanitize 无法识别
|
|
// 占位 → orphan 触发 400。保守跳过:含未闭合占位的段不压缩(降级走 build_for_request 裁剪
|
|
// 或关键词兜底),占位配对完整性由 sanitize step3.5 + 出口断言兜住。
|
|
//
|
|
// 检测:占位 tool_result(content 带 __PENDING__ 标记)的 tool_call_id 不在任何 assistant
|
|
// 头 tool_calls 内 → 未闭合占位 → 返 Err 跳过压缩。
|
|
if compress_segment_has_unclosed_placeholder(&active_msgs) {
|
|
return Err("压缩段含未闭合占位 tool_result,跳过压缩保留原状(防破坏占位配对致 400 orphan)".to_string());
|
|
}
|
|
// MED-1214(CR): 治毒——active_msgs 过 sanitize_messages(畸形 tool 配对自愈),
|
|
// 防 compress 带毒(assistant tool_calls 缺 result / 孤儿 tool_result 无 tool_calls)触发 provider 400。
|
|
// 同 build_for_request 语义:仅发送视图剔毒,不改 ContextManager 持久化。
|
|
let active_count = active_msgs.len();
|
|
let active_msgs = df_ai::context::ContextManager::sanitize_messages(active_msgs);
|
|
|
|
// 路由选模型:无 tool_use(纯文本摘要)。
|
|
// None(池空/无匹配)→ 兜底 default_model(行为不变)。
|
|
let compress_req = TaskRequirements {
|
|
modalities: vec![Modality::Text],
|
|
needs_tool_use: false,
|
|
estimated_context: 0,
|
|
tier: None,
|
|
};
|
|
let model = select_model_id(&compress_req, &provider_config.model_configs)
|
|
.unwrap_or_else(|| provider_config.default_model.clone());
|
|
// 防御:选中/兜底模型是图像模型时返 Err(走关键词摘要兜底,避免文本任务 404 刷屏)。
|
|
if is_likely_image_model(&model, &provider_config.model_configs) {
|
|
return Err(format!(
|
|
"压缩模型 {} 是图像模型,文本任务不可用(检查 provider default_model 应为文本模型)",
|
|
model
|
|
));
|
|
}
|
|
|
|
// 四段式 system prompt + active 消息原文,喂 complete() 非流式出摘要。
|
|
let mut prompt = vec![ChatMessage::system(compress_prompt(lang))];
|
|
prompt.extend(active_msgs);
|
|
let request = CompletionRequest {
|
|
model,
|
|
messages: prompt,
|
|
temperature: Some(0.2),
|
|
max_tokens: Some(1024),
|
|
stream: false,
|
|
tools: None,
|
|
tool_choice: None,
|
|
reasoning_content: None,
|
|
};
|
|
|
|
// LLM 并发限流(压缩属独立调用,纳入双层 Semaphore)
|
|
// F-09 B 批5: per_conv 改 HashMap<conv_id>,压缩在 loop 内针对本对话,用 conv_id 共享限流槽。
|
|
let _global_permit = llm_concurrency.acquire_global().await;
|
|
// compress-permit-try: per_conv 改非阻塞 try。loop 入口已持 1 个 per_conv permit,
|
|
// set_per_conv(1) 后 per_conv Semaphore 仅 1 permit 被入口占满,此处阻塞 acquire 将永久等待
|
|
// → is_compressing 永真 → 卡死。改 try_acquire_per_conv:拿不到(None)提前返 Err 降级
|
|
// (maybe_auto_compress 走失败兜底分支:关键词摘要 + set_compressing(false),下轮再试),非崩溃。
|
|
let _per_conv_permit = match llm_concurrency.try_acquire_per_conv(conv_id).await {
|
|
Some(p) => p,
|
|
None => {
|
|
tracing::warn!(
|
|
conv_id = %conv_id,
|
|
active_count,
|
|
"[ai] 压缩 per_conv permit 已耗尽(loop 入口占用),降级返回走兜底分支,下轮再试"
|
|
);
|
|
return Err(format!(
|
|
"压缩 per_conv 并发限流槽已耗尽(active {} 条,loop 入口占用;降级走兜底,下轮再试)",
|
|
active_count
|
|
));
|
|
}
|
|
};
|
|
|
|
// F-15 卡死根治(2026-06-21 诊断):provider.complete 原无超时,LLM hang → agentic loop
|
|
// (agentic/mod.rs:780)await 永不返 → run_agentic_loop 永挂 → generating 卡死
|
|
// (set_compressing(true) 已置 agentic/mod.rs:753,false 永不到达)。
|
|
// 加 60s timeout 兜底(对齐 ai_approve 超时):超时返 Err → 调用方 Err 分支
|
|
// (agentic/mod.rs:818-830)set_compressing(false) 复位 + 关键词摘要降级,loop 正常继续不卡死。
|
|
let resp = tokio::time::timeout(
|
|
std::time::Duration::from_secs(60),
|
|
provider.complete(request),
|
|
)
|
|
.await
|
|
.map_err(|_| format!("LLM 压缩调用超时(60s,active {} 条,可能 provider hang;降级关键词裁剪)", active_count))?
|
|
.map_err(|e| format!("LLM 压缩调用失败: {} (active {} 条,已 sanitize 治毒;若仍失败查 provider 原文 tool 配对)", e, active_count))?;
|
|
let summary = clean_summary(&resp.text);
|
|
if summary.is_empty() {
|
|
return Err("LLM 压缩返回空摘要".to_string());
|
|
}
|
|
// T3: 解析四段式输出为 JSON 卡片 + NL 摘要
|
|
// 从现有 ## Intent / ## Decisions / ## Files / ## Constraints 格式解析
|
|
let json_card = parse_sections_to_json(&summary);
|
|
Ok(CompressedSummary {
|
|
json_card,
|
|
nl_summary: summary,
|
|
})
|
|
}
|
|
|
|
/// 阶段2(占位配对完整性):检测压缩段是否含未闭合占位 tool_result。
|
|
///
|
|
/// 占位 tool_result(content 带 __PENDING__ 标记,审批挂起)若无对应 tool_call 头(头被裁/丢)
|
|
/// → 未闭合占位。压缩此类段会破坏占位配对(摘要改写 content / 拆散关系),使下游 sanitize
|
|
/// 无法识别占位 → orphan 触发 400。故调用方检测到未闭合占位即跳过压缩(返 Err 降级)。
|
|
///
|
|
/// 检测逻辑:收集段内所有 assistant 头的 tool_call.id;占位 tool_result 的 id 不在内 → 未闭合。
|
|
/// 已闭合占位(头在)可正常压缩(配对完整,摘要不影响头关系)。
|
|
fn compress_segment_has_unclosed_placeholder(msgs: &[ChatMessage]) -> bool {
|
|
use std::collections::HashSet;
|
|
let head_ids: HashSet<&str> = msgs
|
|
.iter()
|
|
.filter(|m| matches!(m.role, df_ai::provider::MessageRole::Assistant))
|
|
.filter_map(|m| m.tool_calls.as_ref())
|
|
.flatten()
|
|
.filter_map(|c| {
|
|
// 排除 TOOL_MISSING_PREFIX 占位头(非真实头,由出口断言自愈补的临时闭合头)
|
|
if c.id.starts_with(df_ai::context_helpers::TOOL_MISSING_PREFIX) {
|
|
None
|
|
} else {
|
|
Some(c.id.as_str())
|
|
}
|
|
})
|
|
.collect();
|
|
msgs.iter().any(|m| {
|
|
matches!(m.role, df_ai::provider::MessageRole::Tool)
|
|
&& df_ai::context_helpers::is_pending_placeholder(&m.content)
|
|
&& m.tool_call_id
|
|
.as_deref()
|
|
.is_some_and(|id| !head_ids.contains(id))
|
|
})
|
|
}
|
|
|
|
/// 从四段式 Markdown 摘要中解析出 JSON 卡片
|
|
/// 段标题: ## Intent / ## Decisions / ## Files / ## Constraints
|
|
fn parse_sections_to_json(summary: &str) -> String {
|
|
let mut topics = Vec::new();
|
|
let mut decisions = Vec::new();
|
|
let mut key_files = Vec::new();
|
|
|
|
let mut current_section: Option<&str> = None;
|
|
for line in summary.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed.starts_with("## ") {
|
|
current_section = trimmed.strip_prefix("## ");
|
|
continue;
|
|
}
|
|
let text = trimmed.trim_start_matches("- ").trim();
|
|
if text.is_empty() {
|
|
continue;
|
|
}
|
|
match current_section {
|
|
Some("Intent") | Some("意图") => {
|
|
topics.push(text.to_string());
|
|
}
|
|
Some("Decisions") | Some("决策") => {
|
|
decisions.push(serde_json::json!({"summary": text}));
|
|
}
|
|
Some("Files") | Some("文件") => {
|
|
key_files.push(serde_json::json!({"change": text}));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
serde_json::json!({
|
|
"topics": topics,
|
|
"decisions": decisions,
|
|
"key_files": key_files,
|
|
"parsed_from": "four_section_summary",
|
|
}).to_string()
|
|
}
|
|
|
|
/// 在文本中提取标记间的内容
|
|
#[allow(dead_code)]
|
|
fn extract_between<'a>(text: &'a str, start_marker: &str, end_marker: &str) -> Option<&'a str> {
|
|
let start = text.find(start_marker)?;
|
|
let after_start = &text[start + start_marker.len()..];
|
|
let end = after_start.find(end_marker)?;
|
|
Some(after_start[..end].trim())
|
|
}
|
|
|
|
/// 清理 LLM 返回的摘要:去首尾空白 / 包裹性 markdown 代码围栏,空则原样返回
|
|
/// (调用方据空值报错)。保留内部 markdown 结构(## 标题 / 列表),摘要本身就是结构化文本。
|
|
///
|
|
/// **退化检测(BUG-2026-07-07/P0-1)**:实测 9357c27c 会话 seq0 system 消息内容为
|
|
/// 同一短语重复 41 次的 1879 字符废文本(LLM 退化输出 / repetition degeneration)。
|
|
/// 原因:某些情况下 LLM(compress 用的 Standard 智力模型)会陷入重复循环,产出形如
|
|
/// "现在更新项目信息:...让我更新项目描述和技术栈。" × N 的退化输出,被当作合法摘要
|
|
/// 塞进 active system 消息,污染每一轮 prompt。此处检测后返空,调用方据空值报错降级
|
|
/// 走关键词摘要兜底(extract_keyword_summary),不让退化输出污染上下文。
|
|
fn clean_summary(raw: &str) -> String {
|
|
let t = raw.trim();
|
|
// 去掉可能的整体代码围栏(LLM 偶尔把整段包成 ```markdown ... ```)
|
|
let t = t
|
|
.strip_prefix("```markdown")
|
|
.or_else(|| t.strip_prefix("```"))
|
|
.unwrap_or(t)
|
|
.trim_start_matches('\n');
|
|
let t = t.strip_suffix("```").unwrap_or(t).trim();
|
|
// 退化检测:若文本含同一片段重复 ≥5 次,判定为 LLM 退化输出,返空让调用方降级。
|
|
// 阈值 5 容忍正常排比(如列表项),只拦死循环式拼接(实测 41 次远超阈值)。
|
|
if is_degenerated_repetition(t) {
|
|
tracing::warn!(
|
|
len = t.len(),
|
|
"[ai] 压缩摘要疑似 LLM 退化重复输出,丢弃降级走关键词兜底"
|
|
);
|
|
return String::new();
|
|
}
|
|
t.to_string()
|
|
}
|
|
|
|
/// 检测 LLM 退化重复输出:文本中是否存在某一长片段(≥8 字符)重复 ≥5 次。
|
|
///
|
|
/// 实测样本:`"我已经了解了项目结构。" + "现在更新项目信息:...让我更新项目描述和技术栈。" × 41`
|
|
/// 即「开头有引导句 + 后接同一片段重复 N 次」形态。
|
|
///
|
|
/// 检测策略:退化输出的重复片段在文本中点附近必然是周期性的,故取**文本中部**一段作候选,
|
|
/// 检查它在全文出现次数。取中点而非前缀,是因为退化输出常带不同前缀引导句
|
|
/// ("我已经了解了项目结构。"),前缀取样会漏检。
|
|
///
|
|
/// **UTF-8 安全**:中文字符占 3 字节,按字节切片会切断字符致 panic。改按 `char_indices`
|
|
/// 取字符边界,确保片段始终在完整字符边界上。
|
|
fn is_degenerated_repetition(text: &str) -> bool {
|
|
if text.len() < 40 {
|
|
return false; // 太短不可能退化
|
|
}
|
|
const MIN_FRAGMENT_CHARS: usize = 8; // 最少 8 个字符(非字节)
|
|
const REPEAT_THRESHOLD: usize = 5;
|
|
// 收集字符边界位置(每个字符起始字节偏移),用于安全切片
|
|
let char_boundaries: Vec<usize> = std::iter::once(0)
|
|
.chain(text.char_indices().map(|(i, _)| i))
|
|
.collect();
|
|
let char_count = char_boundaries.len() - 1;
|
|
if char_count < MIN_FRAGMENT_CHARS * REPEAT_THRESHOLD {
|
|
return false; // 字符太少不可能重复 5 次
|
|
}
|
|
// 从文本中点取样候选片段(退化输出中段必然是周期性重复)
|
|
let mid_char = char_count / 2;
|
|
// 候选片段长度(字符数):从 MIN_FRAGMENT_CHARS 到 40 逐步试
|
|
let max_frag_chars = 40.min(char_count / REPEAT_THRESHOLD);
|
|
for frag_chars in (MIN_FRAGMENT_CHARS..=max_frag_chars).rev() {
|
|
// 取中点附近的 frag_chars 个字符作候选(按字符边界切片)
|
|
let start_char = mid_char.saturating_sub(frag_chars / 2);
|
|
let end_char = (start_char + frag_chars).min(char_count);
|
|
let start_byte = char_boundaries[start_char];
|
|
let end_byte = char_boundaries[end_char];
|
|
let fragment = &text[start_byte..end_byte];
|
|
if fragment.chars().count() < MIN_FRAGMENT_CHARS {
|
|
continue;
|
|
}
|
|
// 用子串计数(不依赖正则,避免引入依赖)
|
|
let count = text.matches(fragment).count();
|
|
if count >= REPEAT_THRESHOLD {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn clean_summary_trims_and_strips_fences() {
|
|
assert_eq!(clean_summary(" hello "), "hello");
|
|
assert_eq!(clean_summary("```markdown\n## 意图\nfoo\n```"), "## 意图\nfoo");
|
|
assert_eq!(clean_summary("```\n## 决策\nbar\n```"), "## 决策\nbar");
|
|
// 保留内部 markdown 结构(摘要本就是结构化)
|
|
assert_eq!(clean_summary("## 意图\n- a\n- b"), "## 意图\n- a\n- b");
|
|
}
|
|
|
|
#[test]
|
|
fn clean_summary_empty_stays_empty() {
|
|
assert_eq!(clean_summary(" "), "");
|
|
assert_eq!(clean_summary(""), "");
|
|
}
|
|
|
|
// ── BUG-2026-07-07/LLM 退化重复输出检测 ──
|
|
#[test]
|
|
fn clean_summary_rejects_degenerated_repetition() {
|
|
// 实测样本(9357c27c seq0):同一短语重复 41 次
|
|
let fragment = "现在更新项目信息:基于代码分析,这是一个大型综合游戏平台后端。让我更新项目描述和技术栈。";
|
|
let degenerated = format!("我已经了解了项目结构。{}", fragment.repeat(41));
|
|
// 退化输出应返空(调用方据空值报错降级走关键词兜底)
|
|
assert_eq!(clean_summary(°enerated), "", "重复拼接的退化输出应被丢弃");
|
|
}
|
|
|
|
#[test]
|
|
fn clean_summary_keeps_normal_summary_with_lists() {
|
|
// 正常摘要(含列表项重复词)不应被误判为退化
|
|
let normal = "## 意图\n- 整理文档\n## 决策\n- 使用方案A\n- 使用方案A\n- 使用方案A\n## 约束\n不超 100 字";
|
|
assert_eq!(clean_summary(normal), normal, "正常摘要不应被误判");
|
|
}
|
|
|
|
#[test]
|
|
fn is_degenerated_repetition_detects_5x_repeat() {
|
|
// 5 次重复阈值边界测试
|
|
let fragment = "这是一个测试片段abcdef";
|
|
assert!(is_degenerated_repetition(&fragment.repeat(5)));
|
|
assert!(is_degenerated_repetition(&fragment.repeat(41)));
|
|
}
|
|
|
|
#[test]
|
|
fn is_degenerated_repetition_ignores_short_text() {
|
|
// 短文本不触发(即使重复)
|
|
assert!(!is_degenerated_repetition("短文本短文本短文本短文本"));
|
|
assert!(!is_degenerated_repetition("ab"));
|
|
}
|
|
|
|
#[test]
|
|
fn compress_prompt_returns_template_per_lang() {
|
|
// 中文模板(默认)
|
|
let zh = compress_prompt("zh");
|
|
assert!(zh.contains("意图"));
|
|
assert!(zh.contains("决策"));
|
|
assert!(zh.contains("文件"));
|
|
assert!(zh.contains("约束"));
|
|
// 英文模板
|
|
let en = compress_prompt("en");
|
|
assert!(en.contains("Intent"));
|
|
assert!(en.contains("Decisions"));
|
|
assert!(en.contains("Files"));
|
|
assert!(en.contains("Constraints"));
|
|
// 未知语言降级中文
|
|
assert_eq!(compress_prompt("fr"), compress_prompt("zh"));
|
|
}
|
|
|
|
// ── 阶段2 占位配对完整性:compress_segment_has_unclosed_placeholder ──
|
|
|
|
#[test]
|
|
fn unclosed_placeholder_detected_when_no_head() {
|
|
// 占位 tool_result(带标记)无对应 tool_call 头 → 未闭合 → 应跳过压缩
|
|
let msgs = vec![
|
|
df_ai::provider::ChatMessage::user("问题"),
|
|
df_ai::provider::ChatMessage::tool_result(
|
|
"call_pending",
|
|
"需要用户审批,等待确认__PENDING__:call_pending",
|
|
),
|
|
];
|
|
assert!(
|
|
compress_segment_has_unclosed_placeholder(&msgs),
|
|
"无头的占位 tool_result 应判为未闭合占位"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn closed_placeholder_not_flagged() {
|
|
// 占位 tool_result 有对应 tool_call 头 → 已闭合 → 不跳过(可正常压缩)
|
|
let msgs = vec![
|
|
df_ai::provider::ChatMessage::user("问题"),
|
|
df_ai::provider::ChatMessage::assistant_with_tools(
|
|
"调",
|
|
vec![df_ai::provider::ToolCall::new(
|
|
"call_pending",
|
|
"write_file",
|
|
"{}",
|
|
)],
|
|
),
|
|
df_ai::provider::ChatMessage::tool_result(
|
|
"call_pending",
|
|
"需要用户审批,等待确认__PENDING__:call_pending",
|
|
),
|
|
];
|
|
assert!(
|
|
!compress_segment_has_unclosed_placeholder(&msgs),
|
|
"有头的占位 tool_result 是已闭合,不应跳过压缩"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn non_placeholder_orphan_not_flagged() {
|
|
// 普通 tool_result(非占位)无头 → 不算"未闭合占位"(普通 orphan 由 sanitize 处理,
|
|
// 不触发压缩跳过;占位保护语义专属于审批挂起占位)
|
|
let msgs = vec![
|
|
df_ai::provider::ChatMessage::user("问题"),
|
|
df_ai::provider::ChatMessage::tool_result("orphan_id", "普通结果"),
|
|
];
|
|
assert!(
|
|
!compress_segment_has_unclosed_placeholder(&msgs),
|
|
"非占位 orphan 不应触发压缩跳过(仅占位专享保护)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_tool_messages_not_flagged() {
|
|
// 纯文本段(无 tool_result)→ 永不触发跳过
|
|
let msgs = vec![
|
|
df_ai::provider::ChatMessage::user("问题1"),
|
|
df_ai::provider::ChatMessage::assistant("回答1"),
|
|
df_ai::provider::ChatMessage::user("问题2"),
|
|
];
|
|
assert!(!compress_segment_has_unclosed_placeholder(&msgs));
|
|
}
|
|
}
|