新增: F-15上下文压缩基础(compress_prompt四段式+compress_via_llm+context辅助方法)
This commit is contained in:
148
src-tauri/src/commands/ai/compress.rs
Normal file
148
src-tauri/src/commands/ai/compress.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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\
|
||||
- 只输出上述四段内容,不要前言、解释或额外评论。",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user