新增: F-15上下文压缩基础(compress_prompt四段式+compress_via_llm+context辅助方法)

This commit is contained in:
2026-06-17 00:50:47 +08:00
parent 76d823ded0
commit 63bff8bf96
4 changed files with 507 additions and 17 deletions

View File

@@ -106,7 +106,7 @@ impl ContextConfig {
/// 消息在逻辑上的分组标签,用于淘汰时保持原子性
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MessageGroup {
pub enum MessageGroup {
/// 普通 User / Assistant 文本消息(可独立淘汰)
Standalone,
/// Assistant 带 tool_calls是三元组的头
@@ -116,10 +116,14 @@ enum MessageGroup {
}
/// 带有 token 缓存和分组信息的消息条目
struct TrackedMessage {
message: ChatMessage,
token_count: u32,
group: MessageGroup,
///
/// 字段 `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,
}
fn classify_group(msg: &ChatMessage) -> MessageGroup {
@@ -150,6 +154,9 @@ pub struct ContextManager {
history_tokens: u32,
config: ContextConfig,
estimator: TokenEstimator,
/// 压缩重入标志F-15 §4.3true 表示一次 LLM 压缩正在进行中。
/// agentic loop 顶部检测,防同一轮内多次触发压缩互相覆盖。纯内存态,不落库。
is_compressing: bool,
}
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
@@ -166,6 +173,7 @@ impl ContextManager {
history_tokens: 0,
config,
estimator: TokenEstimator::default(),
is_compressing: false,
}
}
@@ -194,6 +202,7 @@ impl ContextManager {
pub fn clear(&mut self) {
self.messages.clear();
self.history_tokens = 0;
self.is_compressing = false;
}
/// 消息数量
@@ -522,14 +531,49 @@ impl ContextManager {
self.messages.iter().map(|t| &t.message)
}
// ── 内部方法 ──
// ── F-15 上下文管理增强辅助方法阶段1 基础,零行为变化)──
//
// 这些方法供阶段2 IPCai_chat_compress_context/ 阶段3 agentic loop
// 自动压缩调用。本阶段只暴露方法 + 单测,不接 IPC/前端/agentic.rs。
/// 构建淘汰单元列表
/// 配置(只读视图,供 agentic.rs 计算压缩触发阈值 `config().budget_limit()`
pub fn config(&self) -> &ContextConfig {
&self.config
}
/// 可变消息切片供阶段2 标记 status="compressed"/"archived_segment" + 调整 token
///
/// 每个单元是连续消息范围 [start, end),保证:
/// - 工具调用三元组ToolCallHead + ToolResultTail* + 紧随的文本 Assistant在同一单元
/// - 保护区内的消息不纳入任何单元
fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
/// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走
/// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。
pub fn messages_mut(&mut self) -> &mut [TrackedMessage] {
&mut self.messages
}
/// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。
///
/// 供阶段2 在压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
/// 调用方负责算合法 index,如 `compress_end` 已由 `compress_old_messages` 校验)。
pub fn insert_at(&mut self, index: usize, message: ChatMessage) {
let tokens = self.estimator.estimate_message(&message);
let group = classify_group(&message);
if message.is_active() {
self.history_tokens += tokens;
}
self.messages.insert(index, TrackedMessage {
message,
token_count: tokens,
group,
});
}
/// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。
///
/// 返回每个单元的右开区间 end + token 总和,保证:
/// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元
/// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元
///
/// 公开供阶段2 会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
pub fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
let mut units = Vec::new();
let mut i = 0usize;
@@ -537,14 +581,12 @@ impl ContextManager {
let mut token_sum = 0u32;
if self.messages[i].group == MessageGroup::ToolCallHead {
// 收集完整三元组Head + 后续所有 ToolResultTail + 紧随的文本 Assistant
token_sum += self.messages[i].token_count;
i += 1;
while i < protect_start && self.messages[i].group == MessageGroup::ToolResultTail {
token_sum += self.messages[i].token_count;
i += 1;
}
// 紧随的 Standalone Assistant工具调用的最终文本回复
if i < protect_start
&& self.messages[i].group == MessageGroup::Standalone
&& matches!(self.messages[i].message.role, MessageRole::Assistant)
@@ -553,7 +595,6 @@ impl ContextManager {
i += 1;
}
} else {
// Standalone / ToolResultTail理论上孤立 Tail 不该出现,按单条处理)
token_sum += self.messages[i].token_count;
i += 1;
}
@@ -563,12 +604,64 @@ impl ContextManager {
units
}
/// 保护区外是否存在可压缩消息(供 agentic loop 顶部触发判断)。
///
/// "可压缩"= status 为 None/active 的消息(已 compressed/archived_segment/truncated
/// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。
pub fn has_compressible_messages(&self, protect_start: usize) -> bool {
let end = protect_start.min(self.messages.len());
self.messages[..end]
.iter()
.any(|t| t.message.is_active())
}
/// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`,
/// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供阶段2 喂 LLM 摘要)。
///
/// **幂等**:已 compressed(或任何 !active)的消息跳过,不会被二次压缩;`history_tokens`
/// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。
///
/// **单向不可逆**:压缩后 DB 原始消息保留,但 LLM 上下文里被 is_active 白名单隔离
/// (sanitize step0 过滤)。`compress_end` 越界自动 clamp 到 `messages.len()`。
///
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
let end = compress_end.min(self.messages.len());
let mut newly_compressed = Vec::new();
for t in self.messages[..end].iter_mut() {
if t.message.is_active() {
newly_compressed.push(t.message.clone());
t.message.status = Some("compressed".to_string());
self.history_tokens = self.history_tokens.saturating_sub(t.token_count);
}
}
newly_compressed
}
/// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
pub fn is_compressing(&self) -> bool {
self.is_compressing
}
/// 压缩重入标志(写)。`true`=开始压缩(进入 agentic loop 顶部前置置位),
/// `false`=压缩结束(无论成功或降级)。调用方必须成对调用,防止永久卡死。
pub fn set_compressing(&mut self, v: bool) {
self.is_compressing = v;
}
// ── 内部方法 ──
// (原 build_eviction_units / classify_group 等纯函数已在上方公开或文件级定义;
// ContextManager 的私有辅助如需新增放在这里。)
}
/// 淘汰单元:连续消息范围 [..end) + token 总和
struct EvictionUnit {
end: usize,
token_sum: u32,
///
/// `pub` 供 `build_eviction_units` 的返回类型对外可见(阶段2/3 调用方读 `end` / `token_sum`)。
pub struct EvictionUnit {
pub end: usize,
pub token_sum: u32,
}
#[cfg(test)]
@@ -944,4 +1037,171 @@ mod tests {
msgs.len()
);
}
// ── F-15 阶段1 辅助方法单测 ──
#[test]
fn compress_old_messages_marks_compressed_and_returns_refs() {
// F-15 §4.2/§4.3compress_old_messages 把 [0, end) 内 active 消息标 compressed,
// 同步扣 history_tokens,返回它们的克隆供 LLM 摘要。持久化全量保留。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("旧消息1"));
mgr.push(ChatMessage::assistant("旧回复1"));
mgr.push(ChatMessage::user("新消息2"));
let tokens_before = mgr.history_tokens();
assert!(tokens_before > 0);
let compressed = mgr.compress_old_messages(2);
assert_eq!(compressed.len(), 2, "应压缩前 2 条 active");
assert_eq!(compressed[0].content, "旧消息1");
assert_eq!(compressed[1].content, "旧回复1");
// status 已改 compressed
assert_eq!(mgr.messages_mut()[0].message.status.as_deref(), Some("compressed"));
assert_eq!(mgr.messages_mut()[1].message.status.as_deref(), Some("compressed"));
// 保护区外(本例 index 2)仍 active
assert!(mgr.messages_mut()[2].message.is_active(), "保护区外消息不应被动");
// 持久化全量不变
assert_eq!(mgr.all_messages_clone().len(), 3, "compress 不应删消息(单向,全量保留)");
// token 已扣(剩第 3 条的)
let only_third_tokens = TokenEstimator::default().estimate_message(&ChatMessage::user("新消息2"));
assert_eq!(mgr.history_tokens(), only_third_tokens, "history_tokens 应扣除前两条");
}
#[test]
fn compress_old_messages_is_idempotent() {
// 幂等:已 compressed 不二次压缩,二次调用返回空 Vec 且 history_tokens 不再变。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("a"));
mgr.push(ChatMessage::user("b"));
let first = mgr.compress_old_messages(2);
assert_eq!(first.len(), 2);
let tokens_after_first = mgr.history_tokens();
let second = mgr.compress_old_messages(2);
assert!(second.is_empty(), "二次压缩应返回空(已 compressed 不重压)");
assert_eq!(
mgr.history_tokens(),
tokens_after_first,
"二次压缩 history_tokens 不应再变(幂等)"
);
}
#[test]
fn compress_old_messages_clamps_oversized_end() {
// compress_end 越界自动 clamp 到 len,不 panic。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("唯一"));
let compressed = mgr.compress_old_messages(999);
assert_eq!(compressed.len(), 1, "越界 end 应 clamp 到 len(1)");
assert_eq!(mgr.history_tokens(), 0, "全量压缩后 history_tokens 归零");
}
#[test]
fn compress_old_messages_skips_already_inactive() {
// 范围内含 truncated(已 !active)的消息:跳过,不返,不重复扣 token。
let mut mgr = ContextManager::new(cfg(100_000));
let mut truncated = ChatMessage::user("被截断");
truncated.status = Some("truncated".to_string());
mgr.push(truncated);
mgr.push(ChatMessage::user("active 一条"));
let tokens_before = mgr.history_tokens();
// truncated 已不计 token(见 push_token_only_active),所以 tokens_before 只含 active 一条
let compressed = mgr.compress_old_messages(2);
assert_eq!(compressed.len(), 1, "只压缩 active 那条,truncated 跳过");
assert_eq!(mgr.history_tokens(), 0);
assert_eq!(
mgr.history_tokens(),
tokens_before.saturating_sub(tokens_before),
"幂等扣除:truncated 本就没计 token,active 扣光"
);
// truncated 状态不被改成 compressed(保留原 truncated,语义不混淆)
assert_eq!(
mgr.messages_mut()[0].message.status.as_deref(),
Some("truncated"),
"已 truncated 不应被改写为 compressed"
);
}
#[test]
fn has_compressible_messages_respects_protect_zone() {
let mut mgr = ContextManager::new(cfg(100_000));
for i in 0..8 {
mgr.push(ChatMessage::user(&format!("消息 {}", i)));
}
// protect_start=6 → [0,6) 内有 active → true
assert!(mgr.has_compressible_messages(6));
// protect_start=0 → 空范围 → false
assert!(!mgr.has_compressible_messages(0));
// 全部压缩后 → false
mgr.compress_old_messages(6);
assert!(!mgr.has_compressible_messages(6), "全 compressed 后不应有可压缩消息");
}
#[test]
fn is_compressing_flag_round_trip() {
// 标志位读写 round-trip;clear() 复位。
let mut mgr = ContextManager::new(cfg(100_000));
assert!(!mgr.is_compressing(), "默认 false");
mgr.set_compressing(true);
assert!(mgr.is_compressing(), "set true 后应读到 true");
mgr.set_compressing(false);
assert!(!mgr.is_compressing(), "set false 后复位");
// clear 复位
mgr.set_compressing(true);
mgr.clear();
assert!(!mgr.is_compressing(), "clear() 应复位 is_compressing");
}
#[test]
fn insert_at_adds_to_budget_when_active() {
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("a"));
let tokens_before = mgr.history_tokens();
// 插入 active system 消息 → 计入 token
mgr.insert_at(0, ChatMessage::system("## 摘要"));
assert!(mgr.history_tokens() > tokens_before, "active 消息应计入 token");
assert_eq!(mgr.len(), 2);
assert_eq!(mgr.messages_mut()[0].message.content, "## 摘要");
// 插入 !active 消息 → 不计入 token
let tokens_before_inactive = mgr.history_tokens();
let mut inactive = ChatMessage::user("x");
inactive.status = Some("truncated".to_string());
mgr.insert_at(0, inactive);
assert_eq!(
mgr.history_tokens(),
tokens_before_inactive,
"!active 消息插入不应计 token"
);
}
#[test]
fn build_eviction_units_keeps_triplet_atomic_public() {
// 公开的 build_eviction_units:三元组(Head + Tail + Standalone Assistant)应落同一单元。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("前置"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("c1", "fn", "{}")],
));
mgr.push(ChatMessage::tool_result("c1", "结果"));
mgr.push(ChatMessage::assistant("完成"));
mgr.push(ChatMessage::user("后置"));
// protect_start=5(全部纳入)
let units = mgr.build_eviction_units(5);
// 第一个单元是前置 Standalone(end=1);第二个单元应包含三元组三件套 + 后置应分开
// 确认三元组的 Head+Tail+Assistant 在同一单元(end 跳过 3)
let unit2 = units.iter().find(|u| u.end >= 4).expect("应有跨三元组的单元");
assert!(
unit2.end >= 4,
"三元组三件套应在同一淘汰单元, end={}",
unit2.end
);
}
}

View File

@@ -0,0 +1,148 @@
//! 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::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
// F-15: 压缩路由 — TaskRequirements(Standard 智力即可,无需工具)。压缩是结构化
// 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。
// 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。
use df_ai::router::{
select_model_id, CostTier, IntelligenceTier, 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{Standard 智力, Medium 成本上限, 无 tool_use})`,
/// 池空/无匹配兜底 `default_model`(与 `generate_title_via_llm` 一致)。
///
/// 限流:复用 `LlmConcurrency` 双层 Semaphore(压缩属独立 LLM 调用,纳入全局 + 单对话限流,
/// 不与主 stream_llm 抢资源失控)。
///
/// 注意:本函数只负责"喂 LLM 出摘要文本",不改 ContextManager 状态(标 compressed +
/// 插摘要 system 由调用方做,职责分离,便于单元测试与多调用点复用)。
//
// dead_code:阶段1 基础函数,阶段2 IPC(ai_chat_compress_context)/ 阶段3 agentic
// loop 自动压缩会调用。本阶段只暴露 + 单测,不接调用点(零行为变化原则)。
#[allow(dead_code)]
pub(crate) async fn compress_via_llm(
provider: &dyn LlmProvider,
provider_config: &AiProviderRecord,
active_msgs: Vec<ChatMessage>,
lang: &str,
llm_concurrency: &LlmConcurrency,
) -> Result<String, String> {
if active_msgs.is_empty() {
return Err("无可压缩消息(active 消息为空)".to_string());
}
// 路由选模型:Standard 智力够摘要,无 tool_use,Medium 成本上限。
// None(池空/无匹配)→ 兜底 default_model(行为不变)。
let compress_req = TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: false,
min_intelligence: IntelligenceTier::Standard,
max_cost: Some(CostTier::Medium),
estimated_context: 0,
};
let model = select_model_id(&compress_req, &provider_config.model_configs)
.unwrap_or_else(|| provider_config.default_model.clone());
// 四段式 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,
};
// LLM 并发限流(压缩属独立调用,纳入双层 Semaphore)
let _global_permit = llm_concurrency.acquire_global().await;
let _per_conv_permit = llm_concurrency.acquire_per_conv().await;
let resp = provider
.complete(request)
.await
.map_err(|e| format!("LLM 压缩调用失败: {}", e))?;
let summary = clean_summary(&resp.text);
if summary.is_empty() {
return Err("LLM 压缩返回空摘要".to_string());
}
Ok(summary)
}
/// 清理 LLM 返回的摘要:去首尾空白 / 包裹性 markdown 代码围栏,空则原样返回
/// (调用方据空值报错)。保留内部 markdown 结构(## 标题 / 列表),摘要本身就是结构化文本。
#[allow(dead_code)]
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();
t.to_string()
}
#[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(""), "");
}
#[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"));
}
}

View File

@@ -11,6 +11,7 @@
//! - [`audit`] — 工具调用审计 + pending 审批恢复 + 工具调用处理
//! - [`skills`] — 本机 Claude 技能扫描
//! - [`prompt`] — 系统提示词构建
//! - [`compress`] — F-15 上下文压缩 LLM 摘要(compress_via_llm 公共函数)
//! - [`tool_registry`] — AI 工具注册表构建 + 文件路径校验
//! - [`knowledge_inject`] — 知识库注入 + 提炼
//!
@@ -23,6 +24,7 @@
pub mod agentic;
pub mod audit;
pub mod commands;
pub mod compress;
pub mod conversation;
pub mod knowledge_inject;
pub mod prompt;
@@ -213,6 +215,19 @@ pub struct AiSession {
///
/// 单例一份:当前 AiSession 是全局单例F-09 B 多会话架构落地时改 per-conv
pub iteration_used: usize,
/// F-01 阶段6: 用户指定模型 override(主对话专用)。
///
/// **语义**:前端顶部下拉「指定」模式选的 model_id;None/空=自动模式(路由器选)。
/// 仅主对话(agentic)生效——标题/扫描/灵感等内部调用仍走路由,不读此字段。
///
/// **兜底原则**(agentic.rs run_agentic_loop 内强制):override 非空且在该 provider
/// model_configs 池中 → 用 override;否则用 resolved_model(路由结果)。绝不让
/// override 导致无模型。
///
/// **生命周期**:ai_chat_send/regenerate/edit 写入(随发消息带);新对话/会话切换时清空
/// (防上一对话 override 残留污染新对话)。try_continue_agent_loop 读此字段透传给续跑
/// loop(审批续跑/达 max 续跑保持 override 一致,语义=同一主对话)。
pub model_override: Option<String>,
}
impl AiSession {
@@ -228,6 +243,7 @@ impl AiSession {
stop_flag: Arc::new(AtomicBool::new(false)),
notify: Arc::new(tokio::sync::Notify::new()),
iteration_used: 0,
model_override: None,
}
}

View File

@@ -127,3 +127,69 @@ pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String
prompt
}
/// 上下文压缩 system prompt 模板F-15 §4.2,纯函数无 IO
///
/// 引导 LLM 把对话压缩为四段式结构化摘要(意图/决策/文件/约束),最大化保留
/// 旧消息语义。调用方把返回的 system prompt 放在消息列表首位,后接被压缩的
/// active 消息原文user/assistant/tool喂给 `provider.complete()`
///
/// ```ignore
/// let mut msgs = vec![ChatMessage::system(compress_prompt(lang))];
/// msgs.extend(active_msgs);
/// let resp = provider.complete(CompletionRequest { messages: msgs, .. }).await?;
/// ```
///
/// `lang` 与系统提示词保持一致取值集合("en" 英文,其余一律中文)。
//
// dead_code:阶段1 基础函数,阶段2 IPC(ai_chat_compress_context)与阶段3 agentic
// 自动压缩会经 compress_via_llm 调用本函数。本阶段只暴露 + 单测,不接调用点。
#[allow(dead_code)]
pub(crate) fn compress_prompt(lang: &str) -> &'static str {
match lang {
"en" => "You are a conversation summarizer. Compress the following conversation \
into a structured summary that preserves the essential context for \
continuing the work. Output exactly four sections:\n\
\n\
## Intent\n\
The user's core intent and requirements (what they are trying to achieve).\n\
\n\
## Decisions\n\
Key decisions already made (which approach/option was chosen, why alternatives \
were rejected).\n\
\n\
## Files\n\
Files modified/created/inspected, with a one-line description of the change in each.\n\
\n\
## Constraints\n\
Important constraints, preferences, or open issues to respect going forward.\n\
\n\
Rules:\n\
- Be concise; prefer bullet points.\n\
- Drop small talk and transient pleasantries; keep only technically load-bearing facts.\n\
- Preserve file paths, identifiers, and error messages verbatim.\n\
- Do NOT invent facts not present in the conversation.\n\
- Output the four sections only, no preamble or extra commentary.",
_ => "你是对话总结器。请把以下对话压缩为结构化摘要,保留继续推进工作所必需的关键上下文。\
严格按以下四段输出:\n\
\n\
## 意图\n\
用户的核心意图和需求(用户想要达成什么)。\n\
\n\
## 决策\n\
已做出的关键决策(选了哪种方案/选项,为何排除其他方案)。\n\
\n\
## 文件\n\
已修改/创建/查看过的文件,每条一行简述其变更内容。\n\
\n\
## 约束\n\
重要约束、偏好或待解决的问题,后续需要继续遵守或留意。\n\
\n\
要求:\n\
- 简洁,优先用要点。\n\
- 去掉寒暄、过渡性客套,只保留技术上有价值的事实。\n\
- 文件路径、标识符、错误信息等照原样保留。\n\
- 不要编造对话中没有的事实。\n\
- 只输出上述四段内容,不要前言、解释或额外评论。",
}
}