新增: 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

@@ -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"));
}
}