新增: F-15上下文压缩基础(compress_prompt四段式+compress_via_llm+context辅助方法)
This commit is contained in:
@@ -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.3):true 表示一次 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 IPC(ai_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.3:compress_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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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