新增: 消息级溯源 P2 切 ai_messages + AI Chat 跑题改进 P0-P2 + 标题诊断 + 苛刻测

消息级溯源 P2(一次性切读 b + 备份保留):
- 批次A 读路径切 ai_messages(state.rs AppState.ai_messages + 映射 ChatMessage↔AiMessageRecord +
  switch/export load 切 + fallback 兜底)
- 批次B 写路径切 ai_messages(replace_conversation 单事务全量重写 + save/clear 切 + 旧 messages 备份)
- 摘要/压缩不改 updated_at(save_conversation touch_updated_at,compress false 防时间分组跳变)

AI Chat 跑题改进 P0-P2(5 改进,治跑题 5 根因,三原则优雅/可靠/易迭代):
- P0 系统提示聚焦(## 聚焦准则独立段)+ 意图接入 loop(filter_tool_defs 收敛工具 29→5-10,三重 fallback)
- P1 压缩增强(compress prompt 主题锚点 + 失败关键词兜底 extract_keyword_summary)+
  工具结果压缩(should_summarize + extract_key_info view-only 不改持久化)
- P2 主题检测(TrackedMessage.topic + 双高置信保守标记 + tokenize 2-gram 修复中文锚点)
- title LLM complete 失败诊断日志(助定位返空根因)
- 跑题 P0 审查🟡修复(Debug 加 Data 域 + threshold 注释)

苛刻测 38 条(边界/对抗:全漂移/全停用词/全错误行/2KB 边界/连续主题切换/中文 2-gram/emoji)
df-ai 227 passed + workspace EXIT 0
This commit is contained in:
2026-06-20 03:54:58 +08:00
parent 4e3a11f925
commit de049704c6
11 changed files with 2434 additions and 44 deletions

View File

@@ -41,6 +41,10 @@ pub struct ContextManager {
/// 压缩重入标志F-15 §4.3true 表示一次 LLM 压缩正在进行中。
/// agentic loop 顶部检测,防同一轮内多次触发压缩互相覆盖。纯内存态,不落库。
is_compressing: bool,
/// [P2 改进5] 主题切换检测标记。push user 消息时若发现末两条 user 消息的 topic
/// 都非 None 且不同(双高置信),置位本字段,格式 "old|new"。agentic loop 顶部读并
/// 消费(insert 系统标记后清空)。纯内存态,不落库。保守:任一 topic 为 None 不置位(宁可漏报)。
pending_topic_marker: Option<String>,
}
impl ContextManager {
@@ -51,6 +55,7 @@ impl ContextManager {
config,
estimator: TokenEstimator::default(),
is_compressing: false,
pending_topic_marker: None,
}
}
@@ -68,10 +73,37 @@ impl ContextManager {
if message.is_active() {
self.history_tokens += tokens;
}
// [P2 改进5] 主题推断(仅 user 消息):IntentRecognizer 识别意图,置信 >= 0.7 且
// 非 Unknown 则把 Intent 标签存入 TrackedMessage.topic 供主题切换检测。
// 保守:低置信(None)不标,避免误报。topic 不参与裁剪/压缩(只供 marker 检测)。
let topic: Option<String> = if matches!(message.role, MessageRole::User) {
let (intent, conf) = crate::intent::IntentRecognizer::recognize(&message.content);
if conf >= 0.7 && !matches!(intent, crate::intent::Intent::Unknown) {
Some(intent.as_str().to_string())
} else {
None
}
} else {
None
};
// [P2 改进5] 主题切换检测:push 前(本消息即将成末条 user),若已存在一条更早 user
// 且本消息 topic 与之都非 None 且不同 → 置位 pending_topic_marker("old|new")。
// 双高置信(两条 topic 都非 None)才标,任一 None 不标(宁可漏报不误报)。
if matches!(message.role, MessageRole::User) {
if let Some(prev_topic) = self.last_user_topic() {
if let Some(this_topic) = &topic {
if prev_topic != *this_topic {
self.pending_topic_marker =
Some(format!("{}|{}", prev_topic, this_topic));
}
}
}
}
self.messages.push(TrackedMessage {
message,
token_count: tokens,
group,
topic,
});
}
@@ -80,6 +112,7 @@ impl ContextManager {
self.messages.clear();
self.history_tokens = 0;
self.is_compressing = false;
self.pending_topic_marker = None;
}
/// 消息数量
@@ -493,6 +526,28 @@ impl ContextManager {
self.last_message_id_by_role(MessageRole::User)
}
// ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──
/// 取末条 user 消息的 topic 标签(供 push 时主题切换检测)。
///
/// 从尾部反向扫描 user 消息,取最近一条 role=User 的 TrackedMessage.topic。
/// 老消息(未接改进5 推断)topic=None → 返 None(向前兼容)。无 user 消息 → None。
fn last_user_topic(&self) -> Option<String> {
self.messages
.iter()
.rev()
.find(|t| matches!(t.message.role, MessageRole::User))
.and_then(|t| t.topic.clone())
}
/// 取并消费 pending_topic_marker(供 agentic loop 顶部读 → insert 系统标记 → 清空)。
///
/// 返回 "old|new" 格式字符串(push 时末两条 user topic 都非 None 且不同置位)。
/// 取出即清空(一次性消费,防同 marker 重复 insert)。无 marker → None。
pub fn take_topic_marker(&mut self) -> Option<String> {
self.pending_topic_marker.take()
}
// ── F-15 上下文管理增强辅助方法阶段1 基础,零行为变化)──
//
// 这些方法供阶段2 IPCai_chat_compress_context/ 阶段3 agentic loop
@@ -525,6 +580,7 @@ impl ContextManager {
message,
token_count: tokens,
group,
topic: None,
});
}
@@ -1278,4 +1334,219 @@ mod tests {
mgr.push(message);
id.expect("新构造消息必有 id")
}
// ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──
#[test]
fn topic_marker_triggers_on_two_different_high_conf_topics() {
// 双高置信:两条 user 消息各自 intent 置信 >= 0.7 且不同 topic → 置位 marker。
// "帮我重构这段代码"(Code, conf=1.0) + "创建项目并绑定目录"(Project, conf=1.0)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("帮我重构这段代码"));
// 此时只有一条 user,无 marker
assert!(mgr.take_topic_marker().is_none(), "首条 user 不触发 marker");
mgr.push(ChatMessage::user("创建项目并绑定目录"));
let marker = mgr.take_topic_marker()
.expect("双不同 topic 应置位 marker");
assert!(marker.contains("code"), "old topic 应为 code, 实际: {}", marker);
assert!(marker.contains("project"), "new topic 应为 project, 实际: {}", marker);
// take 后清空(幂等)
assert!(mgr.take_topic_marker().is_none(), "take 后应清空");
}
#[test]
fn topic_marker_not_triggered_on_same_topic() {
// 同 topic(code/code)→ 不置位(非切换)。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码"));
mgr.push(ChatMessage::user("再重构另一段代码"));
assert!(mgr.take_topic_marker().is_none(), "同 topic 不应触发 marker");
}
#[test]
fn topic_marker_not_triggered_when_either_topic_none() {
// 保守:任一 topic 为 None(低置信未标)不置位。
// "今天的天气不错"(Unknown, conf=0.0 → topic=None) + "重构代码"(Code, conf 高)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("今天的天气不错啊"));
mgr.push(ChatMessage::user("帮我重构这段代码"));
assert!(mgr.take_topic_marker().is_none(), "前一条 topic None 不应触发 marker(保守)");
// 反向:前一条高置信 + 后一条低置信
let mut mgr2 = ContextManager::new(cfg(100_000));
mgr2.push(ChatMessage::user("帮我重构这段代码"));
mgr2.push(ChatMessage::user("嗯嗯好的"));
assert!(mgr2.take_topic_marker().is_none(), "后一条 topic None 不应触发 marker(保守)");
}
#[test]
fn topic_marker_not_polluting_compression_or_trim() {
// topic 字段不参与裁剪/压缩(只检测):超预算裁剪 + 压缩后消息仍正常,
// topic 标记独立工作。push 足量消息撑爆小预算触发裁剪。
let mut mgr = ContextManager::new(cfg(80));
for i in 0..10 {
mgr.push(ChatMessage::user(&format!("重构代码第 {} 条长消息撑爆预算", i)));
}
mgr.push(ChatMessage::user("创建项目并绑定目录")); // Project topic → marker
let _ = mgr.take_topic_marker(); // 消费 marker(topic 不影响裁剪)
// 裁剪仍正常工作(topic 字段不参与裁剪逻辑)
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(trimmed);
assert!(!msgs.is_empty());
}
#[test]
fn topic_field_default_none_in_constructors() {
// TrackedMessage.topic 在 push/insert_at 构造时默认 None(push 会按 intent 推断覆盖,
// insert_at 始终 None——insert_at 不做主题推断,保守)。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.insert_at(0, ChatMessage::system("摘要"));
// insert_at 的 system 消息 topic=None(role 非 User)
assert_eq!(mgr.messages_mut()[0].topic, None);
}
// ===== 苁刻测:主题切换检测对抗/边界/链式 =====
#[test]
fn topic_marker_chained_three_switches_each_triggers() {
// 对抗(链式):A→B→C 三次连续主题切换,每次 push 不同 topic 都应置位 marker。
// 验证 last_user_topic 反向扫描取最近 user,链式切换逐次触发不漏。
let mut mgr = ContextManager::new(cfg(100_000));
// 1. Code
mgr.push(ChatMessage::user("帮我重构这段代码")); // topic=code
assert!(mgr.take_topic_marker().is_none(), "首条不触发");
// 2. Code → Project(切换)
mgr.push(ChatMessage::user("创建项目并绑定目录")); // topic=project
let m1 = mgr.take_topic_marker().expect("code→project 应触发");
assert!(m1.contains("code") && m1.contains("project"), "marker1: {}", m1);
assert!(mgr.take_topic_marker().is_none(), "take 后清空");
// 3. Project → Task(切换)
mgr.push(ChatMessage::user("推进这个任务到下一状态")); // topic=task
let m2 = mgr.take_topic_marker().expect("project→task 应触发");
assert!(m2.contains("project") && m2.contains("task"), "marker2: {}", m2);
// 4. Task → Http(切换,证链式不因中间穿插断)
mgr.push(ChatMessage::user("调用接口请求这个 api")); // topic=http
let m3 = mgr.take_topic_marker().expect("task→http 应触发");
assert!(m3.contains("task") && m3.contains("http"), "marker3: {}", m3);
}
#[test]
fn topic_marker_low_confidence_chain_never_triggers() {
// 对抗(低置信链):连续 push 低置信(Unknown)消息 → topic 全 None → 永不置位
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None
assert!(mgr.take_topic_marker().is_none());
mgr.push(ChatMessage::user("啊这样啊")); // Unknown/0.0 → None
assert!(mgr.take_topic_marker().is_none(), "双 None 链不触发");
// 再接一条高置信,但前一条 None → 仍不触发(保守:任一 None 即不标)
mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0
assert!(
mgr.take_topic_marker().is_none(),
"前一条 topic None,即使本条高置信也不触发(双高置信约束)"
);
}
#[test]
fn topic_marker_high_then_low_confidence_never_triggers() {
// 对抗(反向低置信链):高置信 → 低置信,后一条 None → 不触发
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0
assert!(mgr.take_topic_marker().is_none());
mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None
assert!(
mgr.take_topic_marker().is_none(),
"后一条 topic None,即使前一条高置信也不触发"
);
}
#[test]
fn topic_marker_switch_back_to_same_after_different_not_triggered() {
// 边界:A→B→A:第二次 A 时,末两条 user 是 B(高)→ A(高),不同 → 应触发。
// 验证 last_user_topic 只看最近一条 user,不缓存历史(不会因"曾标过 code"漏判)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码")); // code
mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → 触发 code|project
let _ = mgr.take_topic_marker();
mgr.push(ChatMessage::user("再重构另一段代码")); // code(末两条:project→code 不同)
let m = mgr.take_topic_marker().expect("project→code 切回应触发");
assert!(m.contains("project") && m.contains("code"), "切回原 topic 也应触发: {}", m);
}
#[test]
fn topic_field_survives_compress_old_messages() {
// 对抗(topic 跨 compress):compress_old_messages 只改 status,不动 topic 字段。
// 压缩后 compressed 消息的 topic 标签保留(向前兼容,DB/内存一致性)。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码")); // idx 0, topic=code
mgr.push(ChatMessage::user("读取这个文件")); // idx 1, topic=file
mgr.push(ChatMessage::assistant("ok")); // idx 2
// 压缩 [0,2):idx 0/1 标 compressed
let compressed = mgr.compress_old_messages(2);
assert_eq!(compressed.len(), 2);
// topic 字段保留(compress 不触碰)
assert_eq!(mgr.messages_mut()[0].topic.as_deref(), Some("code"), "compressed 消息 topic 应保留");
assert_eq!(mgr.messages_mut()[1].topic.as_deref(), Some("file"), "compressed 消息 topic 应保留");
// status 改为 compressed
assert_eq!(mgr.messages_mut()[0].message.status.as_deref(), Some("compressed"));
}
#[test]
fn topic_marker_system_message_in_send_view_under_adequate_budget() {
// 边界(marker 端到端):取 marker → 调用方 insert_at 顶 system 标记 →
// build_for_request 在充足预算下 system 正常进发送视图(不丢,不 panic)。
// 注:build_for_request 裁剪策略裁前部(保护最近 PROTECT_COUNT 条),顶置 system
// 在极小预算撑爆时会被裁——这是策略既定行为,非 bug。本测验证充足预算下 marker
// 流程端到端正常:marker 取出 + insert system + 发送视图含该 system。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码")); // code
mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → marker
let marker = mgr.take_topic_marker().expect("应触发 marker");
assert!(marker.contains("code") && marker.contains("project"));
// 调用方据 marker insert system 标记(agentic loop 实际行为)
mgr.insert_at(0, ChatMessage::system("[主题切换标记]"));
let (msgs, trimmed) = mgr.build_for_request(0);
assert!(!trimmed, "充足预算不应裁剪");
assert!(
msgs.iter().any(|m| m.content.contains("[主题切换标记]")),
"marker system 应进发送视图: {:?}",
msgs.iter().map(|m| m.content.chars().take(15).collect::<String>()).collect::<Vec<_>>()
);
}
#[test]
fn topic_marker_not_triggered_by_assistant_or_tool_messages() {
// 边界:只有 user 消息参与主题推断;assistant/tool push 不触发 marker(topic=None)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码")); // code
assert!(mgr.take_topic_marker().is_none());
// assistant 消息(含 error 关键词但不影响 topic 推断,因 role 非 User)
mgr.push(ChatMessage::assistant("编译 error 出现了"));
assert!(
mgr.take_topic_marker().is_none(),
"assistant 消息不参与主题推断,不触发 marker"
);
// 再 push user(同 code topic)→ 末两条 user 都是 code,不切换
mgr.push(ChatMessage::user("继续重构"));
assert!(mgr.take_topic_marker().is_none(), "同 topic user 间不切换(assistant 不算)");
// 此时末条 user 是 code,切到 project 应触发
mgr.push(ChatMessage::user("创建项目并绑定目录"));
assert!(mgr.take_topic_marker().is_some(), "code→project 切换应触发(assistant 不打断)");
}
#[test]
fn topic_marker_take_is_idempotent_and_clears() {
// 边界:take_topic_marker 一次性消费,take 两次第二次必 None(防重复 insert)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("重构这段代码"));
mgr.push(ChatMessage::user("创建项目并绑定目录"));
let _ = mgr.take_topic_marker().expect("应有 marker");
// 第二次 take 必 None
assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空");
}
}

View File

@@ -152,6 +152,9 @@ pub struct TrackedMessage {
pub message: ChatMessage,
pub token_count: u32,
pub group: MessageGroup,
/// [P2 改进5] 该消息所属主题标签(意图识别推断,None=未标记)。当前不参与裁剪/压缩,
/// 仅供主题切换检测。向前兼容:TrackedMessage 不 Serialize(只 ChatMessage 落库),无 DB 影响。
pub topic: Option<String>,
}
/// 按消息角色与 tool_calls 判定其逻辑分组(淘汰单元的原子性基础)
@@ -171,6 +174,283 @@ pub fn classify_group(msg: &ChatMessage) -> MessageGroup {
}
}
// ============================================================
// 改进3 B: 压缩失败兜底 — 关键词摘要提取(纯函数,无 LLM)
// ============================================================
/// 压缩失败降级:从消息提取关键词摘要(避免裸裁剪丢主题)。
///
/// 当 LLM 压缩失败时,原本只能裸裁剪保最近 6 条(可能丢失用户反复提到的核心
/// 主题词/实体名)。本函数从 user 消息中提取频次最高的关键词,组装成 system 摘要
/// 塞回首条,作为续接锚点。
///
/// 策略(纯启发式,无 tokenizer 依赖):
/// - 仅取 user 消息(用户表达意图的载体,assistant 多为操作流水)。
/// - 按非字母数字下划线汉字边界分词;保留长度 >= 2 的词。
/// - 去停用词(常见虚词/助词,中英混合)。
/// - 频次 top-N,频次相同按词长降序(长实体优先),再按字典序稳定。
/// - 返回 system 文本(空消息/无可提取词 → 空字符串,调用方判空跳过 insert)。
pub fn extract_keyword_summary(msgs: &[ChatMessage]) -> String {
const TOP_N: usize = 10;
const MIN_WORD_LEN: usize = 2;
// 中英混合停用词表(高频虚词/助词/常见无主题意义词)。
const STOPWORDS: &[&str] = &[
// 中文
"", "", "", "", "", "", "", "", "", "", "", "", "一个",
"", "", "", "", "", "", "", "", "", "", "没有", "", "",
"自己", "", "", "这个", "那个", "什么", "怎么", "可以", "应该", "需要",
"已经", "现在", "然后", "因为", "所以", "但是", "如果", "虽然", "一下", "一些",
// 英文
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "to", "of",
"in", "on", "at", "by", "for", "with", "about", "as", "into", "like", "through",
"after", "over", "between", "and", "but", "or", "not", "no", "yes", "if", "then",
"this", "that", "these", "those", "it", "its", "i", "you", "he", "she", "we",
"they", "them", "my", "your", "his", "her", "our", "their", "do", "does", "did",
"have", "has", "had", "will", "would", "can", "could", "should", "may", "might",
"please", "help", "want", "need", "make", "use", "get",
];
use std::collections::HashMap;
let mut freq: HashMap<String, u32> = HashMap::new();
for msg in msgs {
// 仅 user 消息参与(assistant/tool 多为操作流水,无主题价值)。
if !matches!(msg.role, MessageRole::User) {
continue;
}
for token in tokenize(msg.content.as_str()) {
let word = token.trim();
if word.chars().count() < MIN_WORD_LEN {
continue;
}
if STOPWORDS.contains(&word) {
continue;
}
*freq.entry(word.to_string()).or_insert(0) += 1;
}
}
if freq.is_empty() {
return String::new();
}
let mut entries: Vec<(String, u32)> = freq.into_iter().collect();
// 排序:频次降序 → 词长降序(长实体优先) → 字典序稳定。
entries.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| b.0.chars().count().cmp(&a.0.chars().count()))
.then_with(|| a.0.cmp(&b.0))
});
let keywords: Vec<&str> = entries.iter().take(TOP_N).map(|(w, _)| w.as_str()).collect();
if keywords.is_empty() {
return String::new();
}
format!(
"[上下文压缩失败兜底摘要] 用户在历史对话中反复提及的关键主题词(作为续接锚点保留):\n{}",
keywords.join("")
)
}
/// 简单分词:按非(字母/数字/下划线/汉字)边界切分,保留连续的词素 + 汉字 2-gram。
///
/// 中英混合:连续的拉丁/数字/下划线聚成一个词素(标识符如 build_for_request);
/// 汉字既作为单字词素,又额外产出 2-gram 相邻汉字词素(如"压缩架构"→"压缩"/"缩架"/"架构"),
/// 以捕获 2 字常用词(CR-26 🟡1:此前单字全被 MIN_WORD_LEN=2 滤掉,致"压缩/架构/审批"
/// 这类高频 2 字词在关键词摘要中丢失)。纯启发式,不依赖 jieba(零依赖原则)。
fn tokenize(s: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut buf = String::new();
let mut prev_class: u8 = 0; // 0=none,1=han,2=word(字母数字_),3=other
let mut prev_han: Option<char> = None; // [CR-26] 上一个汉字,供 2-gram 滑窗
for ch in s.chars() {
let class = if ch.is_alphanumeric() && !is_han(ch) {
2
} else if is_han(ch) {
1
} else if ch == '_' {
2
} else {
3
};
if class == 3 {
if !buf.is_empty() {
out.push(std::mem::take(&mut buf));
}
prev_class = 0;
prev_han = None; // 非汉字边界重置 2-gram 窗口(不跨边界组词)
continue;
}
// 词素切换:汉字单独成词(每个汉字一个词素,便于统计高频字组),
// 但连续拉丁/下划线/数字聚成一个词素(标识符如 build_for_request)。
if class == 1 {
// 汉字:先冲掉当前 buf(若有),再把单字作为独立词素。
if !buf.is_empty() {
out.push(std::mem::take(&mut buf));
}
out.push(ch.to_string());
// [CR-26 🟡1] 2-gram 汉字滑窗:与前一个汉字(若连续,prev_class==1)组成 2 字词素。
// "压缩架构"→"压缩"/"缩架"/"架构";"压缩 文件"(空格隔开)→"压缩"/"文件",无跨边界 2-gram。
if let Some(prev) = prev_han {
if prev_class == 1 {
let mut gram = String::new();
gram.push(prev);
gram.push(ch);
out.push(gram);
}
}
prev_han = Some(ch);
prev_class = 1;
} else {
// class == 2:拉丁/数字/下划线聚成连续词素。
// 从汉字切换到拉丁或反向时,先冲掉 buf + 重置 2-gram 窗口。
if prev_class == 1 {
buf.clear();
}
prev_han = None; // 离开汉字区,2-gram 窗口重置
buf.push(ch);
prev_class = 2;
}
}
if !buf.is_empty() {
out.push(buf);
}
out
}
/// 判定是否汉字(统一码 CJK 区间,粗略覆盖常用范围)。
fn is_han(ch: char) -> bool {
matches!(ch as u32,
0x4E00..=0x9FFF // CJK 统一汉字
| 0x3400..=0x4DBF // CJK 扩展 A
| 0xF900..=0xFAFF // CJK 兼容
)
}
// ============================================================
// 改进4: 工具结果压缩(view-only 摘要化纯函数)
// ============================================================
/// tool_result 内容触发摘要压缩的大小阈值(字节,~512 行典型输出)。
pub const TOOL_RESULT_SUMMARIZE_BYTES: usize = 2_048;
/// tool_result 占历史 token 比例阈值(占比超此值触发摘要)。
pub const TOOL_RESULT_SUMMARIZE_RATIO: f32 = 0.40;
/// extract_key_info 保留的首部行数。
pub const TOOL_RESULT_HEAD_LINES: usize = 5;
/// extract_key_info 保留的尾部行数。
pub const TOOL_RESULT_TAIL_LINES: usize = 5;
/// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。
///
/// - `content_len`:tool_result content 字节数。
/// - `history_tokens`:当前历史总 token(用于占比判定)。
/// - `content_tokens`:该 tool_result 自身的 token 估算(供占比)。
///
/// 任一阈值触发即压缩。0 history/0 content 不触发(避免除零/空消息误压)。
pub fn should_summarize_tool_result(
content_len: usize,
history_tokens: u32,
content_tokens: u32,
) -> bool {
if content_len >= TOOL_RESULT_SUMMARIZE_BYTES {
return true;
}
if history_tokens == 0 {
return false;
}
let ratio = content_tokens as f32 / history_tokens as f32;
ratio > TOOL_RESULT_SUMMARIZE_RATIO
}
/// 提取 tool_result 关键信息(纯函数,无 LLM):保留错误行 + 首/尾各若干行,中间省略。
///
/// 策略:
/// - 全文扫一遍,挑出含错误信号的行(`error`/`Error`/`panic`/`失败`/`.rs:N` 文件定位行)。
/// - 保留首 `TOOL_RESULT_HEAD_LINES` 行 + 末 `TOOL_RESULT_TAIL_LINES` 行(上下文边界)。
/// - 错误行(在头尾区间外的)额外插入,标注位置。
/// - 中间大段省略为 `... (省略 N 行) ...`。
///
/// `tool_name` 仅用于摘要头注释,不参与内容判断。空 content 返回空字符串。
pub fn extract_key_info(content: &str, tool_name: &str) -> String {
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() {
return String::new();
}
// 短内容不压缩(行数不足以省略)。
let total = lines.len();
let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
if total <= kept_boundary {
return content.to_string();
}
let head_end = TOOL_RESULT_HEAD_LINES;
let tail_start = total.saturating_sub(TOOL_RESULT_TAIL_LINES);
let mut out: Vec<String> = Vec::new();
out.push(format!("[工具 {} 输出已压缩: 保留首尾 + 错误行, 共 {} 行]", tool_name, total));
// 头部。
for line in &lines[..head_end] {
out.push((*line).to_string());
}
// 中部:扫描错误行(head_end..tail_start 区间内)。
let mut error_lines: Vec<(usize, &str)> = Vec::new();
for (idx, line) in lines.iter().enumerate() {
if idx < head_end || idx >= tail_start {
continue;
}
if is_error_line(line) {
error_lines.push((idx, *line));
}
}
if error_lines.is_empty() {
// 无错误行:单一省略标记。
let omitted = tail_start - head_end;
out.push(format!("... (省略 {} 行) ...", omitted));
} else {
// 有错误行:按位置分段省略 + 插入错误行。
let mut last = head_end;
for (idx, line) in &error_lines {
if *idx > last {
out.push(format!("... (省略 {} 行) ...", idx - last));
}
out.push(format!("[行 {}] {}", idx + 1, line));
last = idx + 1;
}
if last < tail_start {
out.push(format!("... (省略 {} 行) ...", tail_start - last));
}
}
// 尾部。
for line in &lines[tail_start..] {
out.push((*line).to_string());
}
out.join("\n")
}
/// 判定一行是否错误信号行(错误信息/panic/失败/源码定位)。
fn is_error_line(line: &str) -> bool {
let lower = line.to_lowercase();
if lower.contains("error") || lower.contains("panic") {
return true;
}
if line.contains("失败") || line.contains("错误") || line.contains("异常") {
return true;
}
// 源码定位行:常见 `.rs:NN:` / `.rs:NNN` 格式(rust 编译错误/panic 定位)。
if line.contains(".rs:") {
return true;
}
false
}
// ============================================================
// 淘汰单元
// ============================================================
@@ -193,3 +473,460 @@ pub const PROTECT_COUNT: usize = 6;
/// Anthropic 流式 tool_use 缺 id 时的占位 id 前缀(见 anthropic_compat.rs 170-176
/// 此类 id 必然无匹配 tool_result是历史中毒的标志sanitize 时据此剔除畸形三元组。
pub const TOOL_MISSING_PREFIX: &str = "tool_missing_";
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::ChatMessage;
// ── 改进3 B: extract_keyword_summary ──
#[test]
fn keyword_summary_empty_messages() {
assert_eq!(extract_keyword_summary(&[]), "");
}
#[test]
fn keyword_summary_only_assistant_returns_empty() {
// 非 user 消息不参与提取。
let msgs = vec![
ChatMessage::assistant("build_for_request 的实现"),
ChatMessage::assistant("另一个 assistant 消息"),
];
assert_eq!(extract_keyword_summary(&msgs), "");
}
#[test]
fn keyword_summary_chinese_extracts_frequent_terms() {
let msgs = vec![
ChatMessage::user("帮我看看 build_for_request 的实现"),
ChatMessage::assistant("ok"),
ChatMessage::user("build_for_request 怎么裁剪的"),
ChatMessage::user("build_for_request 这个函数有问题"),
];
let summary = extract_keyword_summary(&msgs);
assert!(summary.contains("build_for_request"), "应提取高频标识符, 实际: {}", summary);
assert!(summary.contains("上下文压缩失败兜底摘要"));
}
#[test]
fn keyword_summary_filters_stopwords() {
// 全是停用词/单字 → 返回空。
let msgs = vec![ChatMessage::user("的 了 是 在 我 有 和")];
assert_eq!(extract_keyword_summary(&msgs), "");
}
#[test]
fn keyword_summary_mixed_cn_en() {
let msgs = vec![
ChatMessage::user("检查 ContextManager 的 history_tokens"),
ChatMessage::user("ContextManager token 超预算"),
];
let summary = extract_keyword_summary(&msgs);
assert!(summary.contains("ContextManager"), "应提取英文标识符, 实际: {}", summary);
// token 频次 1 也应入榜(去停用词后剩余的有意义词)。
}
// ── CR-26 🟡1: tokenize 2-gram 汉字(中文 2 字词不再被滤掉) ──
#[test]
fn tokenize_chinese_2gram_produces_bigram_terms() {
// "压缩架构" 应产出 2-gram 词素 "压缩"/"缩架"/"架构",不再只剩单字。
let toks = tokenize("压缩架构");
// 2-gram 入榜
assert!(toks.contains(&"压缩".to_string()), "应产出 2-gram 压缩, 实际: {:?}", toks);
assert!(toks.contains(&"架构".to_string()), "应产出 2-gram 架构, 实际: {:?}", toks);
// 单字也并存
assert!(toks.contains(&"".to_string()));
assert!(toks.contains(&"".to_string()));
}
#[test]
fn tokenize_chinese_2gram_not_crossing_boundary() {
// 空格/标点隔开 → 不跨边界组 2-gram("压缩 文件"→"压缩"/"文件",无"缩文")。
let toks = tokenize("压缩 文件");
assert!(toks.contains(&"压缩".to_string()));
assert!(toks.contains(&"文件".to_string()));
assert!(!toks.contains(&"缩文".to_string()), "空格边界不应跨字组 2-gram");
}
#[test]
fn tokenize_english_identifier_unchanged() {
// 既有英文标识符不被 2-gram 改造:连续拉丁聚成一个词素。
let toks = tokenize("build_for_request is cool");
assert!(toks.contains(&"build_for_request".to_string()), "标识符应保持完整, 实际: {:?}", toks);
assert!(toks.contains(&"is".to_string()));
assert!(toks.contains(&"cool".to_string()));
}
#[test]
fn keyword_summary_chinese_2gram_terms_in_top() {
// CR-26 🟡1 验证:中文 2 字常用词经 2-gram 入榜,不再被 MIN_WORD_LEN=2 滤掉。
let msgs = vec![
ChatMessage::user("压缩上下文 token 超预算了"),
ChatMessage::user("架构需要重构压缩逻辑"),
];
let summary = extract_keyword_summary(&msgs);
// "压缩"出现两次(2-gram)应入榜;"架构"频次 1 也应有机会。
assert!(
summary.contains("压缩") || summary.contains("架构"),
"中文 2 字词经 2-gram 应入榜, 实际: {}",
summary
);
}
// ── 改进4: should_summarize_tool_result ──
#[test]
fn should_summarize_short_content_below_byte_threshold() {
// 100 字节 + history 充足,占比低 → 不压缩。
assert!(!should_summarize_tool_result(100, 10_000, 35));
}
#[test]
fn should_summarize_large_bytes_triggers() {
// > 2KB 触发。
assert!(should_summarize_tool_result(3_000, 100_000, 100));
}
#[test]
fn should_summarize_high_ratio_triggers() {
// content 占 history 50% > 40% 阈值触发(content_len 未超 2KB)。
assert!(should_summarize_tool_result(1_000, 1_000, 500));
}
#[test]
fn should_summarize_zero_history_no_panic() {
// 0 history + 小 content → 不压缩且不除零 panic。
assert!(!should_summarize_tool_result(100, 0, 50));
}
// ── 改进4: extract_key_info ──
#[test]
fn extract_key_info_short_content_unchanged() {
let content = "line1\nline2\nline3";
assert_eq!(extract_key_info(content, "read_file"), content);
}
#[test]
fn extract_key_info_empty_returns_empty() {
assert_eq!(extract_key_info("", "read_file"), "");
}
#[test]
fn extract_key_info_keeps_head_tail_and_errors() {
let mut lines: Vec<String> = (1..=20).map(|i| format!("line {}", i)).collect();
// 在中间插入一行 rust 错误定位(第 12 行,索引 11)。
lines[11] = "error[E0308]: mismatched types at src/foo.rs:42:13".to_string();
let content = lines.join("\n");
let result = extract_key_info(&content, "cargo_build");
assert!(result.contains("已压缩"), "应含压缩标记, 实际: {}", result);
assert!(result.contains("line 1"), "应保留首行");
assert!(result.contains("line 20"), "应保留末行");
assert!(result.contains("src/foo.rs:42"), "应保留错误行");
assert!(result.contains("行 12"), "错误行应标注原始位置");
assert!(result.contains("省略"), "应含省略标记");
}
#[test]
fn extract_key_info_detects_panic_and_failure() {
let lines: Vec<String> = (1..=15)
.map(|i| {
if i == 8 {
"thread main panicked at lib.rs:10".to_string()
} else if i == 9 {
"执行失败".to_string()
} else {
format!("row {}", i)
}
})
.collect();
let content = lines.join("\n");
let result = extract_key_info(&content, "run_cmd");
assert!(result.contains("panicked"));
assert!(result.contains("lib.rs:10"));
assert!(result.contains("执行失败"));
}
#[test]
fn extract_key_info_no_errors_single_omission() {
// 中间无错误行 → 单一省略标记。
let lines: Vec<String> = (1..=20).map(|i| format!("data {}", i)).collect();
let content = lines.join("\n");
let result = extract_key_info(&content, "list_dir");
// 计算省略标记出现次数:中间 10 行省略,应仅 1 个省略段。
let omission_count = result.matches("省略").count();
assert_eq!(omission_count, 1, "无错误行应单一省略, 实际 {} 次: {}", omission_count, result);
}
// ===== 苛刻测:对抗 + 边界 + 极端 =====
// ── extract_keyword_summary:对抗/极端 ──
#[test]
fn keyword_summary_no_user_messages_returns_empty_no_panic() {
// 极端:全 assistant/tool 消息,无 user → 返空串(不 panic)
let msgs = vec![
ChatMessage::assistant("我读了文件"),
ChatMessage::tool_result("call_1", "{\"ok\":true}"),
ChatMessage::assistant("完成"),
];
assert_eq!(extract_keyword_summary(&msgs), "");
}
#[test]
fn keyword_summary_all_stopwords_returns_empty() {
// 对抗(全停用词):user 消息全是"的/了/是/在/我"(单字 + 停用词双过滤)
// → 全被滤,返空串
let msgs = vec![ChatMessage::user("的 了 是 在 我 有 和 就 不 人 都 一 上")];
assert_eq!(extract_keyword_summary(&msgs), "");
}
#[test]
fn keyword_summary_single_long_message_caps_top10() {
// 极端(单条 10K 字 user):top-10 不超 10,高频词正确入榜。
// 构造 20 个不同高频词各重复 5 次 + 大量填充,验证 take(TOP_N=10) 生效。
let mut content = String::new();
// 20 个候选词,每个写 5 次(词频递减可验排序)
let words: Vec<&str> = (0..20).map(|_| "高频词").collect();
for (i, w) in words.iter().enumerate() {
for _ in 0..5 {
content.push_str(w);
content.push_str(&format!("序号{} ", i)); // 拉丁数字区分不同实例
}
}
// 填充到 ~10K 字
while content.chars().count() < 10_000 {
content.push_str("填充内容x ");
}
let msgs = vec![ChatMessage::user(content)];
let summary = extract_keyword_summary(&msgs);
// 摘要非空 + 关键词数 <= 10(逗号分隔统计)
assert!(summary.contains("上下文压缩失败兜底摘要"));
// 取摘要中关键词行(第二行),按"、"切分数
let kw_line = summary.lines().nth(1).unwrap_or("");
let kw_count = kw_line.split('、').count();
assert!(kw_count <= 10, "top-10 应不超 10, 实际 {} 个: {}", kw_count, kw_line);
assert!(kw_count >= 1, "应至少提取 1 个关键词");
}
#[test]
fn keyword_summary_mixed_cn_en_2gram_and_english_both_ranked() {
// 对抗(中英混合 + 2-gram):"read 压缩架构 file" →
// 中文 2-gram("压缩"/"架构") + 英文("read"/"file") 均能被 tokenize 产出
let toks = tokenize("read 压缩架构 file");
assert!(toks.contains(&"压缩".to_string()), "应产 2-gram 压缩, 实际: {:?}", toks);
assert!(toks.contains(&"架构".to_string()), "应产 2-gram 架构, 实际: {:?}", toks);
assert!(toks.contains(&"read".to_string()));
assert!(toks.contains(&"file".to_string()));
// 经 extract_keyword_summary(去停用词 + MIN_LEN=2):
// - "read"(4 字母)入榜;"file"(4 字母)入榜
// - "压缩"/"架构"(2 字 2-gram)入榜
let msgs = vec![ChatMessage::user("read 压缩架构 file")];
let summary = extract_keyword_summary(&msgs);
assert!(summary.contains("压缩") || summary.contains("架构"), "中文 2-gram 应入榜: {}", summary);
assert!(summary.contains("read"), "英文 read 应入榜: {}", summary);
assert!(summary.contains("file"), "英文 file 应入榜: {}", summary);
}
#[test]
fn keyword_summary_pure_punctuation_emoji_returns_empty() {
// 对抗(纯标点/emoji 无实质词):所有字符非字母数字汉字 → tokenize 返空 → 摘要空
let msgs = vec![ChatMessage::user("🎉,。!?...... 😊 👍")];
assert_eq!(extract_keyword_summary(&msgs), "");
}
// ── should_summarize_tool_result:边界阈值 ──
#[test]
fn should_summarize_exactly_2048_bytes_triggers() {
// 边界:content_len == TOOL_RESULT_SUMMARIZE_BYTES(2048)→ >= 触发(true)
assert!(
should_summarize_tool_result(TOOL_RESULT_SUMMARIZE_BYTES, 100_000, 1),
"刚好 2048 字节(>= 阈值)应触发"
);
}
#[test]
fn should_summarize_just_below_2048_no_trigger_when_low_ratio() {
// 边界:content_len = 2047(< 2048)+ 占比极低 → 不触发
assert!(
!should_summarize_tool_result(TOOL_RESULT_SUMMARIZE_BYTES - 1, 100_000, 1),
"2047 字节 + 低占比不应触发"
);
}
#[test]
fn should_summarize_zero_history_zero_content_no_panic() {
// 极端:history=0 + content_len=0 + content_tokens=0 → 不触发,不除零 panic
assert!(!should_summarize_tool_result(0, 0, 0));
}
#[test]
fn should_summarize_zero_history_below_byte_threshold_no_trigger() {
// 边界:history=0(除零短路返 false)+ content_len < 2048 → 不触发
// (history=0 时占比分支被短路,只看字节阈值)
assert!(!should_summarize_tool_result(500, 0, 999_999), "history=0 时占比分支短路,字节未超不触发");
}
#[test]
fn should_summarize_ratio_exactly_40_percent_no_trigger() {
// 边界(严格 >):占比 == 0.40 不触发(用 > 而非 >=)。
// content_tokens / history_tokens = 40/100 = 0.40,严格大于判定为 false。
// 但 f32 精度:40/100=0.4 精确,0.4 > 0.40 → false。
assert!(
!should_summarize_tool_result(100, 100, 40),
"占比 == 40% 不触发(严格 >), 实际占比 {}",
40.0_f32 / 100.0_f32
);
}
#[test]
fn should_summarize_ratio_just_above_40_percent_triggers() {
// 边界:占比略超 40% → 触发。content_len < 2048 走占比分支。
// 41/100 = 0.41 > 0.40 → true
assert!(
should_summarize_tool_result(100, 100, 41),
"占比 41% > 40% 应触发"
);
}
// ── extract_key_info:对抗(全错误行/单行超长) ──
#[test]
fn extract_key_info_all_error_lines_preserved() {
// 对抗(全错误行):行数 > HEAD+TAIL(11),且每行都含 error → 全保留不省略
let lines: Vec<String> = (1..=15).map(|i| format!("error: failure {}", i)).collect();
let content = lines.join("\n");
let result = extract_key_info(&content, "run_cmd");
// 15 行全含 error,首 5(头) + 末 5(尾)区间外 5 行也全是 error → 全部保留
for i in 1..=15 {
assert!(
result.contains(&format!("failure {}", i)),
"错误行 failure {} 应保留, result: {}",
i,
result
);
}
// 不应出现省略(全错误行被插入,无中间省略段)
assert!(!result.contains("省略"), "全错误行不应省略, result: {}", result);
}
#[test]
fn extract_key_info_single_line_no_newline_unchanged() {
// 边界(无换行):单行(无 \n)→ lines() 返 1 行,total <= kept_boundary → 原样返回
let content = "single line no newline";
assert_eq!(extract_key_info(content, "read_file"), content);
}
#[test]
fn extract_key_info_single_huge_line_no_newline_unchanged() {
// 极端(单行 50KB 无换行):lines() 返 1 行 → 原样返回(不走首尾切分)
let content = "x".repeat(50_000);
let result = extract_key_info(&content, "read_file");
assert_eq!(result, content, "单行无换行应原样返回(即使超长)");
}
#[test]
fn extract_key_info_exactly_head_tail_boundary_no_compression() {
// 边界:行数 == HEAD+TAIL(10)→ total <= kept_boundary → 原样返回(不压缩)
let lines: Vec<String> = (1..=10).map(|i| format!("line {}", i)).collect();
let content = lines.join("\n");
let result = extract_key_info(&content, "read_file");
assert_eq!(result, content, "10 行(== HEAD+TAIL)应原样不压缩");
}
#[test]
fn extract_key_info_eleven_lines_triggers_compression() {
// 边界:行数 == 11(刚超 kept_boundary=10)→ 触发压缩,含标记
let lines: Vec<String> = (1..=11).map(|i| format!("line {}", i)).collect();
let content = lines.join("\n");
let result = extract_key_info(&content, "read_file");
assert!(result.contains("已压缩"), "11 行应触发压缩");
assert!(result.contains("line 1"), "保留首行");
assert!(result.contains("line 11"), "保留末行");
}
#[test]
fn extract_key_info_error_line_outside_boundary_kept_with_index() {
// 边界:错误行恰在头部区间内(idx < head_end)→ 不重复插入(头部已含)
// 错误行在尾部区间内(idx >= tail_start)→ 不重复插入(尾部已含)
// 错误行在中间区间 → 标注 [行 N] 插入
let mut lines: Vec<String> = (1..=20).map(|i| format!("norm {}", i)).collect();
// idx=2(头部区间 [0,5))错误行 → 头部已含,不在 error_lines(扫描跳过 head/tail)
lines[2] = "error in head zone".to_string();
// idx=18(尾部区间 [15,20))错误行 → 尾部已含
lines[18] = "error in tail zone".to_string();
// idx=10(中间)错误行 → 标注插入
lines[10] = "error in middle".to_string();
let content = lines.join("\n");
let result = extract_key_info(&content, "read_file");
// 中间错误行被标注插入(原始行号 11)
assert!(result.contains("[行 11] error in middle"), "中间错误行应标注插入: {}", result);
// 头/尾错误行原样保留(无 [行 N] 标注)
assert!(result.contains("error in head zone"));
assert!(result.contains("error in tail zone"));
}
// ── tokenize:对抗(中文长实体/emoji/全角/混合标点) ──
#[test]
fn tokenize_chinese_long_entity_produces_overlapping_2grams() {
// 对抗(中文长实体):"跨端架构设计"(5 字)→
// 单字:跨/端/架/构/设/计(6 个?不,5 字 5 个单字)
// 2-gram:跨端/端架/架构/构建/设计(5 个,相邻滑窗)
let toks = tokenize("跨端架构设计");
// 单字
for ch in "跨端架构设计".chars() {
assert!(toks.contains(&ch.to_string()), "单字 {} 应产出: {:?}", ch, toks);
}
// 2-gram(相邻滑窗)
assert!(toks.contains(&"跨端".to_string()), "应含 2-gram 跨端: {:?}", toks);
assert!(toks.contains(&"端架".to_string()), "应含 2-gram 端架: {:?}", toks);
assert!(toks.contains(&"架构".to_string()), "应含 2-gram 架构: {:?}", toks);
// 相邻滑窗:构→设 相邻产 2-gram "构设"(非"构建",构建非相邻字)
assert!(toks.contains(&"构设".to_string()), "应含 2-gram 构设(相邻滑窗): {:?}", toks);
assert!(toks.contains(&"设计".to_string()), "应含 2-gram 设计: {:?}", toks);
}
#[test]
fn tokenize_emoji_and_fullwidth_punctuation_excluded() {
// 对抗(emoji/全角标点):🎉//。非字母数字汉字 → class=3 → 不入词素,重置 2-gram 窗口
let toks = tokenize("架构,🎉设计");
// "架构"(,前)、"设计"(🎉后)各成 2-gram;全角逗号/emoji 隔断,无"构设"跨边界 2-gram
assert!(toks.contains(&"架构".to_string()));
assert!(toks.contains(&"设计".to_string()));
assert!(!toks.contains(&"构设".to_string()), "emoji/全角标点应隔断 2-gram");
// emoji 本身不入词素
assert!(!toks.iter().any(|t| t.contains('🎉')), "emoji 不应入词素: {:?}", toks);
}
#[test]
fn tokenize_mixed_punctuation_separates_latin_words() {
// 对抗(混合标点分隔):"a,b,c" → 逗号分隔 → "a"/"b"/"c" 三个独立词素
// (但单字母 a/b/c 因 MIN_WORD_LEN=2 在 summary 阶段被滤,tokenize 层应产出)
let toks = tokenize("a,b,c");
assert!(toks.contains(&"a".to_string()));
assert!(toks.contains(&"b".to_string()));
assert!(toks.contains(&"c".to_string()));
// 不应有 "abc" 或 "a,b" 这种带标点的聚合
assert!(!toks.iter().any(|t| t.contains(',')), "标点不应聚入词素: {:?}", toks);
}
#[test]
fn tokenize_empty_string_returns_empty() {
// 极端:空串 → 空 Vec(不 panic)
let toks = tokenize("");
assert!(toks.is_empty());
}
#[test]
fn tokenize_underscore_keeps_identifier() {
// 边界:下划线属 class=2,标识符 build_for_request 保持完整
let toks = tokenize("call build_for_request now");
assert!(toks.contains(&"build_for_request".to_string()));
assert!(toks.contains(&"call".to_string()));
assert!(toks.contains(&"now".to_string()));
}
}

View File

@@ -337,11 +337,11 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
///
/// 设计Code → [file, http]File → [file]Project/Task/Idea → [data]
/// Http → [http]Search → [file](含 search_filesConversation → [];
/// Chat → []Debug → [file, http](调试常需读文件+查 APIUnknown → [](全量)。
/// Chat → []Debug → [file, http, data](调试常需读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具Unknown → [](全量)。
pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
let domains: &[ToolDomain] = match intent {
Intent::Code => &[ToolDomain::File, ToolDomain::Http],
Intent::Debug => &[ToolDomain::File, ToolDomain::Http],
Intent::Debug => &[ToolDomain::File, ToolDomain::Http, ToolDomain::Data],
Intent::File => &[ToolDomain::File],
Intent::Project => &[ToolDomain::Data],
Intent::Task => &[ToolDomain::Data],
@@ -378,6 +378,34 @@ pub fn suggested_model_tier(_intent: &Intent) -> Option<ModelTier> {
None
}
// ---- 工具子集过滤agentic loop 接入用改进2 A -----------------------------
/// 按意图过滤 `tool_defs`subset 非空只留命中,空返回全量(低置信/Unknown/Chat fallback
///
/// **接入语义**改进2 Bagentic loop 在调用 LLM 前用本函数收敛 LLM 可见工具集,
/// 减少跑题(如纯闲聊不暴露文件操作工具)。三重 fallback 保证安全:
/// 1. `tool_subset_for(intent)` 空Chat/Unknown/Conversation→ 返回 `all_defs` 全量。
/// 2. subset 工具名在 `all_defs` 找不到 → 跳过该名(防 registry 漂移导致过滤后为空)。
/// 3. 调用方再做「过滤后 < 3 条 → 回全量」的兜底(见 agentic loop
///
/// **关键安全**filter 仅影响 LLM 可见 tool_defs**不影响执行**audit 走 tools_arc
/// get/execute 完整 registryLLM 即使幻觉一个被滤掉的工具名audit 也能查到/拒绝)。
pub fn filter_tool_defs(
all_defs: &[df_ai_core::types::ToolDefinition],
intent: &Intent,
) -> Vec<df_ai_core::types::ToolDefinition> {
let subset = tool_subset_for(intent);
if subset.is_empty() {
return all_defs.to_vec();
}
let allowed: std::collections::HashSet<&str> = subset.iter().copied().collect();
all_defs
.iter()
.filter(|d| allowed.contains(d.function.name.as_str()))
.cloned()
.collect()
}
// ---- 单测 -------------------------------------------------------------------
#[cfg(test)]
@@ -679,4 +707,234 @@ mod tests {
let (i, _) = IntentRecognizer::recognize("hi");
assert_eq!(i, Intent::Chat);
}
// --- filter_tool_defs改进2 A ---
/// 构造测试用 ToolDefinition仅 name 有意义description/parameters 填占位)。
fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition {
df_ai_core::types::ToolDefinition {
tool_type: "function".to_string(),
function: df_ai_core::types::ToolFunction {
name: name.to_string(),
description: String::new(),
parameters: serde_json::json!({}),
},
}
}
#[test]
fn filter_subset_empty_returns_all_for_unknown() {
// Unknown → subset 空 → fallback 全量
let all = vec![tool_def("read_file"), tool_def("create_project")];
let out = filter_tool_defs(&all, &Intent::Unknown);
assert_eq!(out.len(), all.len(), "Unknown subset 空应回全量");
}
#[test]
fn filter_subset_empty_returns_all_for_chat() {
// Chat → subset 空 → fallback 全量(低意图/闲聊不收敛)
let all = vec![tool_def("read_file"), tool_def("write_file")];
let out = filter_tool_defs(&all, &Intent::Chat);
assert_eq!(out.len(), all.len(), "Chat subset 空应回全量");
}
#[test]
fn filter_subset_empty_returns_all_for_conversation() {
let all = vec![tool_def("read_file")];
let out = filter_tool_defs(&all, &Intent::Conversation);
assert_eq!(out.len(), 1, "Conversation subset 空应回全量");
}
#[test]
fn filter_subset_hit_filters_to_matching() {
// File 意图 subset = File domain(read_file/write_file/...);Http 工具应被滤掉
let all = vec![
tool_def("read_file"),
tool_def("write_file"),
tool_def("http_request"), // 不在 File domain
];
let out = filter_tool_defs(&all, &Intent::File);
assert!(out.iter().any(|d| d.function.name == "read_file"));
assert!(out.iter().any(|d| d.function.name == "write_file"));
assert!(
!out.iter().any(|d| d.function.name == "http_request"),
"File 意图不应含 http_request"
);
assert_eq!(out.len(), 2);
}
#[test]
fn filter_subset_drift_skips_missing_names() {
// subset 命中但 all_defs 里没有对应工具(registry 漂移)→ 跳过,不 panic
// Project 意图 subset = Data domain(create_project/...),all 里只放了一个 Data 工具 + 一个无关工具
let all = vec![
tool_def("create_project"), // 命中
tool_def("http_request"), // 不在 Data domain,滤掉
// 其余 Data domain 工具名在 subset 里但 all 没有 → 跳过
];
let out = filter_tool_defs(&all, &Intent::Project);
assert_eq!(out.len(), 1);
assert_eq!(out[0].function.name, "create_project");
}
#[test]
fn filter_all_drift_returns_empty_not_panic() {
// 极端漂移:subset 命中但 all_defs 完全不交集 → 返回空(调用方做 <3 回全量兜底)
let all = vec![tool_def("totally_unknown_tool")];
let out = filter_tool_defs(&all, &Intent::File);
assert!(out.is_empty(), "全部漂移应返空(交调用方兜底)");
}
#[test]
fn filter_preserves_input_order_for_matching() {
// 过滤后顺序应跟 all_defs 一致(filter 保留原序)
let all = vec![
tool_def("write_file"),
tool_def("read_file"),
tool_def("patch_file"),
tool_def("http_request"),
];
let out = filter_tool_defs(&all, &Intent::File);
let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
assert_eq!(names, vec!["write_file", "read_file", "patch_file"]);
}
// ===== 苛刻测:对抗 + 边界 + 极端(filter_tool_defs / tool_subset_for) =====
#[test]
fn filter_total_drift_subset_names_none_in_registry_returns_empty_no_panic() {
// 对抗:registry 全改名(File subset 工具名一个不在 all_defs)
// → filter 返空 Vec(调用方 <3 回全量兜底)。证不 panic。
let all = vec![
tool_def("renamed_read_file_v2"),
tool_def("totally_other_tool"),
tool_def("weird_name_xyz"),
];
let out = filter_tool_defs(&all, &Intent::File);
assert!(out.is_empty(), "全漂移应返空(交调用方兜底), 实际: {}", out.len());
}
#[test]
fn filter_partial_drift_keeps_only_intersecting() {
// 对抗:subset 含 N 个工具名,all_defs 只命中其中 2 个,其余跳过(不 panic,不补全)
let all = vec![
tool_def("read_file"),
tool_def("patch_file"),
tool_def("unrelated_thing"),
];
let out = filter_tool_defs(&all, &Intent::File);
let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
assert_eq!(names, vec!["read_file", "patch_file"], "部分漂移只留交集, 顺序跟 all");
}
#[test]
fn filter_empty_all_defs_no_panic() {
// 极端:all_defs 空(无工具注册)。无论 intent 怎样都不 panic,返空 Vec。
let empty: Vec<_> = vec![];
let out_unknown = filter_tool_defs(&empty, &Intent::Unknown);
let out_file = filter_tool_defs(&empty, &Intent::File);
assert!(out_unknown.is_empty());
assert!(out_file.is_empty(), "空 registry + File 意图应返空不 panic");
}
#[test]
fn filter_debug_subset_spans_three_domains_complete() {
// 对抗(多 domain):Debug subset 跨 File + Http + Data 三 domain,
// 必须同条消息里能同时命中三个 domain 的工具(防 domain 漏挂)
let all = vec![
// Data domain
tool_def("list_tasks"),
tool_def("create_project"),
tool_def("run_workflow"),
// File domain
tool_def("read_file"),
tool_def("patch_file"),
// Http domain
tool_def("http_request"),
];
let out = filter_tool_defs(&all, &Intent::Debug);
let names: Vec<String> = out.iter().map(|d| d.function.name.clone()).collect();
assert!(names.contains(&"list_tasks".to_string()), "Debug 必含 Data(list_tasks), 防 CR-25 🟡-1 丢 Data");
assert!(names.contains(&"read_file".to_string()), "Debug 必含 File");
assert!(names.contains(&"http_request".to_string()), "Debug 必含 Http");
assert_eq!(names.len(), 6, "三 domain 工具全保留, 实际: {:?}", names);
}
#[test]
fn filter_low_confidence_unknown_subset_empty_falls_back_full() {
// 对抗(低置信):"帮我看看这个"无任何关键词 → recognize 返 Unknown/0.0
// → tool_subset_for(Unknown) 空 → filter 返全量(fallback 链完整)
let (intent, conf) = IntentRecognizer::recognize("帮我看看这个");
assert_eq!(intent, Intent::Unknown, "无关键词应 Unknown");
assert_eq!(conf, 0.0, "置信度应 0.0");
assert!(tool_subset_for(&intent).is_empty(), "Unknown subset 应空");
let all = vec![tool_def("read_file"), tool_def("write_file"), tool_def("http_request")];
let out = filter_tool_defs(&all, &intent);
assert_eq!(out.len(), all.len(), "Unknown/低置信应回全量 fallback");
}
#[test]
fn subset_all_intents_covered_names_in_registry() {
// 对抗(完整性):遍历所有 Intent 的 subset,每个工具名都能在对应 ToolDomain.tools() 找到。
// 防 subset 表写错工具名(如 read_filez 笔误)。subset 工具名必须真存在于其声明 domain。
let all_intents = [
Intent::Code,
Intent::Debug,
Intent::File,
Intent::Project,
Intent::Task,
Intent::Idea,
Intent::Conversation,
Intent::Search,
Intent::Http,
Intent::Chat,
Intent::Unknown,
];
for intent in all_intents {
let subset = tool_subset_for(&intent);
// 全 registry 工具名(三 domain 并集)
let registry: std::collections::HashSet<&str> = ToolDomain::Data
.tools()
.iter()
.chain(ToolDomain::File.tools().iter())
.chain(ToolDomain::Http.tools().iter())
.copied()
.collect();
for name in &subset {
assert!(
registry.contains(*name),
"Intent {:?} subset 含工具名 {} 不在任何 domain 注册表",
intent,
name
);
}
}
}
#[test]
fn filter_subset_correct_intent_for_multi_domain_message() {
// 对抗(跨 domain 完整):"调试任务并读文件"意图多 domain,
// 但 recognize 按优先级定单一 intent,filter 据此收敛。
// 关键:无论 recognize 落哪个 intent,该 intent 的 subset 必须覆盖任务+文件相关工具。
let (intent, conf) = IntentRecognizer::recognize("调试这个任务");
// "调试"(Debug/SPECIFIC, 1.0) 优先于 "任务"(Task/ENTITY)
assert_eq!(intent, Intent::Debug);
assert!(conf >= 0.7);
let subset = tool_subset_for(&intent);
// Debug subset 必含 list_tasks(Data) + read_file(File) 防"调试任务"丢工具
assert!(subset.contains(&"list_tasks"), "Debug 应含 list_tasks(Data domain)");
assert!(subset.contains(&"read_file"), "Debug 应含 read_file(File domain)");
}
#[test]
fn subset_code_and_debug_dedup_http_across_domains() {
// 边界:Code = [File, Http], Debug = [File, Http, Data]
// File + Http 都含 http_request,必须去重(subset 内不重复)
for intent in [Intent::Code, Intent::Debug] {
let s = tool_subset_for(&intent);
let http_count = s.iter().filter(|n| **n == "http_request").count();
assert_eq!(http_count, 1, "Intent {:?} subset http_request 应去重为 1", intent);
}
}
}

View File

@@ -186,6 +186,61 @@ impl AiMessageRepo {
.map_err(storage_err)?
}
/// 全量重写对话的消息(单事务 DELETE + INSERT OR IGNORE,原子)。
///
/// F-260619-03 批次 B(save_conversation 写路径切 ai_messages)的核心方法:
/// 全量重写语义——以入参 records 为该对话的**唯一真相**,先删该 conv 全部旧行再批量插。
/// 单事务保证「删 + 插」原子,无中间空窗(reload 不会读到半删半插的中间态)。
///
/// 设计权衡(非 dirty 增量,留 P2.1 优化):
/// - 内存 ContextManager 是运行时真相源,save 是同步点,每轮 save 全量回写简单可靠;
/// - INSERT OR IGNORE 幂等:records 内 id 重复或与残留行(理论不应有,事务已 DELETE)冲突跳过;
/// - 入参 records 的 conversation_id 应一致(调用方 save_conversation 保证),本方法不校验。
///
/// 返回 Ok(()) —— 不返回受影响行数(DELETE + INSERT 两条计数语义混乱,调用方只关心成功)。
pub async fn replace_conversation(
&self,
conv_id: &str,
records: Vec<AiMessageRecord>,
) -> Result<()> {
let conn = self.conn.clone();
let conv_id = conv_id.to_owned();
tokio::task::spawn_blocking(move || -> Result<()> {
let mut guard = conn.blocking_lock();
let tx = guard.transaction().map_err(storage_err)?;
{
// 先删该 conv 全部旧行(全量重写语义)
tx.execute(
"DELETE FROM ai_messages WHERE conversation_id = ?1",
params![conv_id],
)
.map_err(storage_err)?;
// 再批量插新行(INSERT OR IGNORE 幂等,id 冲突跳过)
if !records.is_empty() {
let mut stmt = tx.prepare(
"INSERT OR IGNORE INTO ai_messages
(id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
)
.map_err(storage_err)?;
for rec in &records {
stmt.execute(params![
rec.id, rec.conversation_id, rec.seq, rec.role, rec.content,
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at
])
.map_err(storage_err)?;
}
}
}
tx.commit().map_err(storage_err)?;
Ok(())
})
.await
.map_err(storage_err)?
}
/// 按 tool_call_id 定点更新消息 content(replace_tool_result_content 用)。
///
/// 返回是否实际更新(0 = 该 tool_call_id 在此对话无对应消息)。
@@ -365,4 +420,162 @@ mod tests {
let got = repo.list_by_conversation("c").await.expect("list");
assert_eq!(got[0].content, "替换后的结果", "其他消息不应被改");
}
// ---------- replace_conversation(F-260619-03 批次 B)----------
/// replace_conversation 全量重写:删旧 + 插新原子,list 一致
#[tokio::test]
async fn replace_conversation_full_rewrite() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiMessageRepo::new(&db);
// 预置旧数据(将被 replace 删除)
repo.insert_batch(vec![
mk_msg("old_0", "conv", 0, "user", "旧0"),
mk_msg("old_1", "conv", 1, "user", "旧1"),
])
.await
.expect("insert old");
// replace 成全新内容(完全不同的 id,旧 id 应被删)
let now = now_millis_str();
let records = vec![
AiMessageRecord {
id: "new_0".into(),
conversation_id: "conv".into(),
seq: 0,
role: "user".into(),
content: "新0".into(),
parts: None,
tool_call_id: None,
tool_calls: None,
model: Some("deepseek-chat".into()),
status: "active".into(),
reasoning_content: Some("思考".into()),
timestamp: Some(1700000000000),
created_at: now.clone(),
},
AiMessageRecord {
id: "new_1".into(),
conversation_id: "conv".into(),
seq: 1,
role: "assistant".into(),
content: "新1".into(),
parts: None,
tool_call_id: None,
tool_calls: Some(r#"[{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}}]"#.into()),
model: None,
status: "active".into(),
reasoning_content: None,
timestamp: None,
created_at: now,
},
];
repo.replace_conversation("conv", records).await.expect("replace");
let got = repo.list_by_conversation("conv").await.expect("list");
assert_eq!(got.len(), 2, "旧 2 条应被删,新 2 条入");
assert_eq!(got[0].id, "new_0");
assert_eq!(got[0].content, "新0");
assert_eq!(got[0].model.as_deref(), Some("deepseek-chat"));
assert_eq!(got[0].reasoning_content.as_deref(), Some("思考"));
assert_eq!(got[1].id, "new_1");
assert!(got[1].tool_calls.is_some());
// 确认旧 id 已删
let ids: Vec<&str> = got.iter().map(|r| r.id.as_str()).collect();
assert!(!ids.contains(&"old_0") && !ids.contains(&"old_1"));
}
/// replace_conversation 空列表 = 清空该对话消息(单事务 DELETE 不插)
#[tokio::test]
async fn replace_conversation_empty_clears() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiMessageRepo::new(&db);
repo.insert_batch(vec![
mk_msg("m0", "c", 0, "user", "0"),
mk_msg("m1", "c", 1, "user", "1"),
])
.await
.expect("insert");
repo.replace_conversation("c", vec![]).await.expect("replace empty");
let got = repo.list_by_conversation("c").await.expect("list");
assert!(got.is_empty(), "空 records 应清空对话");
}
/// replace_conversation 对话隔离:两 conv 并发写不串
#[tokio::test]
async fn replace_conversation_isolation_between_convs() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiMessageRepo::new(&db);
// 预置 conv_a 与 conv_b
repo.insert_batch(vec![mk_msg("a0", "conv_a", 0, "user", "a0")])
.await
.expect("insert a");
repo.insert_batch(vec![mk_msg("b0", "conv_b", 0, "user", "b0")])
.await
.expect("insert b");
// 只 replace conv_a,conv_b 不应被影响
let now = now_millis_str();
repo.replace_conversation(
"conv_a",
vec![AiMessageRecord {
id: "a_new".into(),
conversation_id: "conv_a".into(),
seq: 0,
role: "user".into(),
content: "a_new".into(),
parts: None,
tool_call_id: None,
tool_calls: None,
model: None,
status: "active".into(),
reasoning_content: None,
timestamp: None,
created_at: now,
}],
)
.await
.expect("replace a");
let got_a = repo.list_by_conversation("conv_a").await.expect("list a");
assert_eq!(got_a.len(), 1);
assert_eq!(got_a[0].id, "a_new", "conv_a 应被全量重写");
let got_b = repo.list_by_conversation("conv_b").await.expect("list b");
assert_eq!(got_b.len(), 1, "conv_b 不应被影响");
assert_eq!(got_b[0].id, "b0");
assert_eq!(got_b[0].content, "b0");
}
/// replace_conversation 幂等性:INSERT OR IGNORE 在同事务内 DELETE 后无残留,
/// 重复 replace 同 id 不报错(DELETE 后表内该 conv 空,INSERT 必然成功)
#[tokio::test]
async fn replace_conversation_idempotent_rerun() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiMessageRepo::new(&db);
let now = now_millis_str();
let rec = || AiMessageRecord {
id: "m0".into(),
conversation_id: "c".into(),
seq: 0,
role: "user".into(),
content: "0".into(),
parts: None,
tool_call_id: None,
tool_calls: None,
model: None,
status: "active".into(),
reasoning_content: None,
timestamp: None,
created_at: now.clone(),
};
repo.replace_conversation("c", vec![rec()]).await.expect("1st");
repo.replace_conversation("c", vec![rec()]).await.expect("2nd");
let got = repo.list_by_conversation("c").await.expect("list");
assert_eq!(got.len(), 1, "重复 replace 不应叠加");
}
}

View File

@@ -8,6 +8,13 @@ use tokio::sync::Mutex;
use df_ai::ai_tools::AiToolRegistry;
use df_ai::context::TokenEstimator;
// 改进3 B: 压缩失败兜底关键词摘要(纯函数 extract_keyword_summary)。
// 改进4: 工具结果 view-only 摘要(should_summarize_tool_result / extract_key_info)。
use df_ai::context_helpers::{
extract_key_info, extract_keyword_summary, should_summarize_tool_result,
};
// 改进2 B:意图收敛工具(LLM 可见 tool_defs 按 intent 过滤,执行路径仍走完整 registry)
use df_ai::intent::{filter_tool_defs, IntentRecognizer};
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
// CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类)
// 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。
@@ -57,6 +64,32 @@ const PROTECT_COUNT: usize = 6;
/// partial_text不重试——保文入库 + AiCompleted(incomplete=true) + 系统提示网络中断。
pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3;
/// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。
///
/// true(默认):LLM 压缩失败 → compress_old_messages 标 compressed(释放 token)
/// + insert_at(0, system 关键词摘要),保留用户反复提及的主题词作续接锚点。
/// false(回退):回退原裸裁剪行为(不插关键词摘要,仅 build_for_request 裁剪保最近 6 条)。
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
pub const KEYWORD_FALLBACK_ENABLED: bool = true;
/// 改进4 常量开关:tool_result 大输出是否做 view-only 摘要压缩(默认 true)。
///
/// true(默认):build_for_request 后送 stream 前,遍历 history 中 tool_result,
/// 超阈值(content >2KB 或占历史 token >40%)的 content 应用 extract_key_info
/// 替换(保留错误行 + 首尾各 5 行)。**仅 messages clone 视图,不改 ContextManager
/// 持久化**(对齐 sanitize_messages:DB 原始 tool_result 完整保留)。
/// false(关闭):tool_result 原样送 LLM(旧行为)。排障/对比用。
pub const TOOL_RESULT_COMPRESS_ENABLED: bool = true;
/// 改进5 常量开关:是否启用主题切换系统标记(默认 true)。
///
/// true(默认):push 时末两条 user 消息 topic 都非 None 且不同(双高置信)→
/// pending_topic_marker 置位 → agentic loop 顶部读取并 insert 一条 system 软提示
/// `"── 用户已切换话题(从「{old}」到「{new}」),请以新话题为准 ──"`(软提示,不强制 LLM)。
/// false(关闭):loop 顶部跳过读取/insert(置 false 即可观察无主题标记效果)。
/// 保守:双高置信才标(任一 topic None 不标),不强制 LLM(软提示非硬约束)。
pub const TOPIC_MARKER_ENABLED: bool = true;
// ============================================================
// 重构第一批(2026-06-19):GeneratingGuard 抽离到 guard.rs(纯结构搬迁,行为零变更)。
// run_agentic_loop 内仍 `GeneratingGuard::new(...)`,路径从本模块改 super::guard。
@@ -420,7 +453,55 @@ pub(crate) async fn run_agentic_loop(
}
_ => resolved_model,
};
let tool_defs = tools_arc.tool_definitions();
// 改进2 B:意图收敛工具(LLM 可见 tool_defs 按 intent 过滤,执行路径仍走完整 registry)
//
// 机制化收敛跑题:取末条 active user 消息 → IntentRecognizer 识别 → 置信 ≥ 阈值
// 则按 intent domain 过滤工具子集(减少 LLM 在无关工具上分心/误用)。
// 三重 fallback(改进2 B §可靠):
// 1. 置信 < INTENT_CONF_THRESHOLD(0.7)→ 全量
// 2. subset 空(Chat/Unknown/Conversation)→ filter_tool_defs 内部回全量
// 3. 过滤后 < 3 条(疑似漂移/误收敛)→ 回全量
// 关键安全:filter 仅改 LLM 可见 tool_defs,**不改执行**——audit 走 tools_arc.get/execute
// 完整 registry,LLM 即使幻觉一个被滤掉的工具名,audit 也能查到/拒绝。
//
// INTENT_CONF_THRESHOLD(常量开关):阈值,低于此值不收敛(回全量)。
// 注意:conf 截断到 1.0(intent.rs),单关键词 weight=1.0 即达 conf=1.0,故阈值=1.0
// 时 conf>=1.0 仍过滤(非关闭)。真关闭收敛:置 >1.0(如 1.1)。调低 = 更激进收敛。
let user_text: String = {
let session = session_arc.lock().await;
session
.conv_read(&conv_id)
.and_then(|c| {
let msgs = c.messages.all_messages_clone();
msgs.iter()
.rev()
.find(|m| matches!(m.role, df_ai::provider::MessageRole::User))
.map(|m| m.content.clone())
})
.unwrap_or_default()
};
let (intent, conf) = IntentRecognizer::recognize(&user_text);
const INTENT_CONF_THRESHOLD: f32 = 0.7;
let all_defs = tools_arc.tool_definitions();
let total = all_defs.len(); // 提前记录全量数(all_defs 将 move 进 tool_defs)
let tool_defs = if conf >= INTENT_CONF_THRESHOLD {
let filtered = filter_tool_defs(&all_defs, &intent);
if filtered.len() < 3 {
all_defs // 兜底:过滤<3(漂移/误收敛)回全量
} else {
filtered
}
} else {
all_defs // 低置信 fallback 全量
};
tracing::info!(
conv_id = %conv_id,
intent = intent.as_str(),
conf,
filtered = tool_defs.len(),
total,
"[ai] 意图收敛工具"
);
// 停止信号副本stream_llm 与每轮迭代共享读取,避免重复加锁
// notify 同取一份 Arc 引用B-260615-14stream_llm select! 监听 notified() 即时唤醒
// F-260616-09 B 批2:取 per_conv 的 stop_flag/notify(设计 §4.2 :446)。
@@ -467,7 +548,7 @@ pub(crate) async fn run_agentic_loop(
total_tokens: tokens.total(),
};
// 入口 stop:本轮可能尚未 stream(首轮即停),不记 model——避免把未实际生成的 model 写入 models 数组
save_conversation(&session_arc, &db, &conv_id, Some(&usage), None).await;
save_conversation(&session_arc, &db, &conv_id, Some(&usage), None, true).await;
// 标题生成后台化:不阻塞 Completed emit失败有 extract_title 兜底)
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
guard.reset().await;
@@ -476,6 +557,56 @@ pub(crate) async fn run_agentic_loop(
return;
}
// 改进5: 主题切换系统标记(保守,双高置信才标,软提示非强制)。
//
// push 时若末两条 user 消息的 topic 都非 None 且不同(Intent 高置信推断的双 topic),
// ContextManager 已置位 pending_topic_marker("old|new")。loop 顶部读取并消费
// (take 一次性清空,防重复 insert),insert 一条 system 软提示告知 LLM 用户已切换话题。
//
// 保守设计:
// - 双高置信:两条 topic 都非 None(都达 0.7 阈值)才标,任一 None(低置信未标)不标。
// - 软提示:仅 insert 一条 system 消息,不强制 LLM 行为(LLM 仍可按自己理解响应)。
// - TOPIC_MARKER_ENABLED(常量开关)false → 跳过(排障/对比用)。
// topic 不参与裁剪/压缩(只供检测),insert_at(0) 同压缩摘要定位(首位 system)。
if TOPIC_MARKER_ENABLED {
let topic_marker_raw: Option<String> = {
let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(&conv_id) {
tracing::warn!(
stale_conv = %conv_id,
"[ai] conv 已删除,loop 退出(主题标记段入口)"
);
return;
}
let conv = session.conv(&conv_id);
conv.messages.take_topic_marker()
};
if let Some((old_topic, new_topic)) = topic_marker_raw.and_then(|s| {
// 解析 "old|new" 格式;splitn 防 topic 名内含 '|' 误切(仅切首 '|' 一次)。
let mut parts = s.splitn(2, '|');
let old = parts.next()?.to_string();
let new = parts.next()?.to_string();
Some((old, new))
}) {
let marker_text = format!(
"── 用户已切换话题(从「{}」到「{}」),请以新话题为准 ──",
old_topic, new_topic
);
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) {
let conv = session.conv(&conv_id);
conv.messages.insert_at(0, ChatMessage::system(&marker_text));
tracing::info!(
conv_id = %conv_id,
iteration,
old_topic = %old_topic,
new_topic = %new_topic,
"[ai] 主题切换标记已 insert(软提示,保守双高置信)"
);
}
}
}
// B-260615-11: 旧 loop 污染防护——每轮开始校验对话一致性。
// 用户新建/切换对话后 active_conversation_id 变更,本 loop(conv_id 快照)成陈旧,
// 继续跑会往新对话 push 消息/pending 造成污染。检测到即退出(guard Drop 复位 generating)。
@@ -577,6 +708,15 @@ pub(crate) async fn run_agentic_loop(
(active_msgs, lang)
};
// 改进3 B:在 active_msgs move 进 compress_via_llm 前,先算关键词摘要兜底文本。
// LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。
// KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。
let keyword_fallback: String = if KEYWORD_FALLBACK_ENABLED {
extract_keyword_summary(&active_msgs)
} else {
String::new()
};
// 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。
// 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop。
let compress_outcome: Result<Option<String>, String> = if active_msgs.is_empty() {
@@ -621,15 +761,46 @@ pub(crate) async fn run_agentic_loop(
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
}
Err(e) => {
// LLM 失败 → 消息状态完全不变(未改 status / 未扣 token)。
// set_compressing(false) 复位 + emit AiError(message 不含 api_key)。
// 不阻塞 loop:继续走下方 build_for_request 裁剪路径(保最近 6 条)。
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。
//
// 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。
// 新行为(KEYWORD_FALLBACK_ENABLED=true 默认):
// - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token,
// 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文);
// - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点;
// - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。
// 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。
// KEYWORD_FALLBACK_ENABLED=false → 跳过兜底,等价旧行为(set_compressing(false) +
// 消息状态不变,降级 build_for_request 裁剪)。
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
tracing::warn!(
conv_id = %conv_id,
error = %e,
"[ai] 自动压缩失败,降级走原裁剪(build_for_request)"
keyword_fallback_len = keyword_fallback.len(),
"[ai] 自动压缩失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
KEYWORD_FALLBACK_ENABLED,
);
if KEYWORD_FALLBACK_ENABLED {
// 标 compressed 释放 token + 关键词摘要塞首位(若非空)。
let inserted = {
let mut session = session_arc.lock().await;
let conv = session.conv(&conv_id);
let _compressed = conv.messages.compress_old_messages(protect_start);
if !keyword_fallback.is_empty() {
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback));
true
} else {
false
}
};
if inserted {
tracing::info!(
conv_id = %conv_id,
pre_tokens = pre_compress_tokens,
"[ai] 压缩失败兜底:关键词摘要已插首位(compressed 标记已扣 token)"
);
}
}
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError {
error: format!("自动上下文压缩失败,已降级为普通裁剪: {}", e),
error_type: Some(ErrorType::Unknown),
@@ -660,6 +831,68 @@ pub(crate) async fn run_agentic_loop(
msgs
};
// 改进4: tool_result view-only 摘要压缩(build_for_request 后,送 stream 前)。
//
// 遍历 messages(history clone,已含 system prompt + sanitize 后历史)中的 role=Tool 消息,
// 超阈值(content >2KB 或占历史 token >40%)的 content 应用 extract_key_info 替换:
// 保留错误行(error/panic/失败/.rs:N)+ 首/尾各 5 行,中间省略。
//
// **view-only**:messages 是 build_for_request 返回的 clone,改它只影响本轮 LLM 请求视图,
// 不改 ContextManager 持久化(对齐 sanitize_messages:DB 原始 tool_result 完整保留)。
// 故即使摘要有误/过度压缩,下次 build_for_request 仍从 DB 全量重建,可自愈。
//
// TOOL_RESULT_COMPRESS_ENABLED=false(常量开关)→ 跳过(原样送 LLM,排障/对比用)。
let messages: Vec<ChatMessage> = if TOOL_RESULT_COMPRESS_ENABLED {
// 历史总 token 用于占比判定(history_tokens 即 build_for_request 前的快照,
// 此处 messages 已裁剪过,用 estimated_prompt 不准;改用 history_tokens 快照更稳)。
// 注:用 conv.messages.history_tokens() 快照做占比基准(裁剪前的真相),避免循环依赖。
let history_tokens_snapshot: u32 = {
let session = session_arc.lock().await;
session
.conv_read(&conv_id)
.map(|c| c.messages.history_tokens())
.unwrap_or(0)
};
let est = TokenEstimator::default();
let mut compressed_bytes: usize = 0;
let mut original_bytes: usize = 0;
let mut summarized_count: usize = 0;
let msgs: Vec<ChatMessage> = messages
.into_iter()
.map(|mut m| {
if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
return m;
}
let content_len = m.content.len();
let content_tokens = est.estimate_text(&m.content);
if !should_summarize_tool_result(content_len, history_tokens_snapshot, content_tokens) {
return m;
}
original_bytes += content_len;
// tool_name:无 tool_calls 关联(本消息是 tool_result,无 name 字段),用 call_id 或占位。
let tool_name = m.tool_call_id.clone().unwrap_or_else(|| "tool".to_string());
let compressed = extract_key_info(&m.content, &tool_name);
compressed_bytes += compressed.len();
summarized_count += 1;
m.content = compressed;
m
})
.collect();
if summarized_count > 0 {
tracing::info!(
conv_id = %conv_id,
iteration,
summarized_count,
original_bytes,
compressed_bytes,
"[ai] tool_result view-only 摘要压缩(不改持久化)"
);
}
msgs
} else {
messages
};
// 预估输入 token(兜底:部分 provider 如 GLM 流式 usage 不报 prompt_tokens,后段用它补)
// 注:stream_one_provider 内每次重试重建 request(因 provider.stream 消费 body),
// 此处不再预构建 request(旧 request 变量已废弃),仅保留 messages 供 estimated_prompt。
@@ -829,7 +1062,7 @@ pub(crate) async fn run_agentic_loop(
}
}
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model)).await;
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await;
// 标题生成后台化(失败有 extract_title 兜底)
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
guard.reset().await;
@@ -895,7 +1128,7 @@ pub(crate) async fn run_agentic_loop(
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
};
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model)).await;
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await;
// 标题生成后台化:不阻塞 Completed emit失败有 extract_title 兜底)
spawn_ensure_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, &llm_concurrency);
guard.reset().await;
@@ -920,7 +1153,7 @@ pub(crate) async fn run_agentic_loop(
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
};
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model)).await;
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await;
// B-260615-26: 审批等待 return 前 disarm guard——保持 generating=true 留 try_continue 续生成,
// 同时 Drop 因 done=true 跳过复位 spawn(避免误复位审批态 generating 致 ai_approve→try_continue 不续)
guard.disarm();
@@ -949,7 +1182,7 @@ pub(crate) async fn run_agentic_loop(
completion_tokens: tokens.completion(),
total_tokens: tokens.total(),
};
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model)).await;
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await;
// 暂停态保持 generating=true(防其他 send 抢占,仿审批),disarm guard 跳过 Drop 兜底复位
guard.disarm();
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiMaxRoundsReached {
@@ -979,7 +1212,7 @@ pub(crate) async fn run_agentic_loop(
let llm_concurrency = llm_concurrency.clone();
let resolved_model = resolved_model.clone();
tauri::async_runtime::spawn(async move {
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model)).await;
save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await;
// 知识提炼:需读已落库的对话消息,故在 save 之后
if let Err(e) = maybe_spawn_extraction(&session_arc, &db, &conv_id, &provider_config, &knowledge_config, llm_concurrency.clone()).await {
tracing::warn!("知识提炼触发失败(非阻断): {}", e);

View File

@@ -155,7 +155,7 @@ pub async fn ai_regenerate(
}
// 落库:弹出后的历史先持久化(前端立即反映已删旧回复;loop 内再 save 覆盖)
save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await;
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await;
let session_arc = state.ai_session.clone();
let tools_arc = state.ai_tools.clone();
@@ -402,7 +402,7 @@ pub async fn ai_approve(
// 拒绝结果立即落库(含 recovered 积压审批)——switch 时已 restore_from_messages 载完整历史,
// messages 非空,save 不会污染老对话;原 if !recovered 守卫前提不成立已移除。
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None).await;
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
}
// 审计:拒绝(决策者=human
audit_finalize(&state, &tool_call_id, "rejected", None).await;
@@ -504,7 +504,7 @@ pub async fn ai_approve(
// 含 recovered 积压审批——switch 时已 restore_from_messages 载完整历史,messages 非空,
// save 不污染老对话;原 if !recovered 守卫前提不成立已移除。
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None).await;
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
}
// F-260616-11 决策 a: 审批续跑 iteration 累计(不重置)——读 per_conv.iteration_used 透传
@@ -576,7 +576,7 @@ pub async fn ai_authorize_dir(
});
audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg)).await;
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None).await;
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
}
let cont_conv_id = conv_id.clone().unwrap_or_default();
let start_iter = {
@@ -635,7 +635,7 @@ pub async fn ai_authorize_dir(
conversation_id: conv_id.clone(),
});
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None).await;
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
}
let cont_conv_id = conv_id.clone().unwrap_or_default();
let start_iter = {
@@ -688,13 +688,20 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
}
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != active_id.as_deref());
drop(session);
// 真删 DB:清空该对话 messages JSON + 清零 token(保留对话壳),刷新不再恢复(AR-7)
// 真删 DB:清空该对话 messages(JSON 备份列同步清空 + 清零 token,保留对话壳),刷新不再恢复(AR-7)
// F-260619-03 批次 B:同时清空 ai_messages 表(全删,delete_range min_seq=0 max=None)
// 旧 clear_messages(置 messages='[]')保留调用:备份列同步清空,防 reload fallback 读旧脏数据。
if let Some(id) = active_id {
state
.ai_conversations
.clear_messages(&id)
.await
.map_err(err_str)?;
state
.ai_messages
.delete_range(&id, 0, None)
.await
.map_err(err_str)?;
}
Ok(())
}
@@ -740,7 +747,7 @@ pub async fn ai_chat_clear_context(
conversation_id
};
// 落库持久化新 status(照 save_conversation 模式,DB 持久化新 status)
save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await;
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await;
let _ = app.emit("ai-chat-event", AiChatEvent::AiContextCleared {
conversation_id: Some(conv_id),
});
@@ -867,7 +874,7 @@ pub async fn ai_chat_compress_context(
conv.messages.insert_at(0, ChatMessage::system(&summary));
conv.messages.set_compressing(false);
}
save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await;
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await;
let _ = app.emit("ai-chat-event", AiChatEvent::AiCompressed {
conversation_id: Some(conv_id),
summary,
@@ -955,7 +962,7 @@ pub async fn ai_chat_edit(
}
// 落库:编辑+截断后的历史先持久化(前端立即反映已截断旧回复)
save_conversation(&state.ai_session, &state.db, &conv_id, None, None).await;
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await;
let session_arc = state.ai_session.clone();
let tools_arc = state.ai_tools.clone();

View File

@@ -15,7 +15,8 @@ use std::sync::atomic::Ordering;
use tauri::{AppHandle, State};
use df_ai::provider::ChatMessage;
use df_ai::provider::{ChatMessage, MessageRole};
use df_storage::models::AiMessageRecord;
use df_types::types::new_id;
use crate::state::AppState;
@@ -26,6 +27,124 @@ use super::super::conversation::save_conversation;
use super::super::prompt::get_active_provider;
use super::super::title::spawn_ensure_title;
// ============================================================
// ChatMessage ↔ AiMessageRecord 映射(F-260619-03 消息拆分存储)
// ============================================================
//
// 读路径(批次 A)核心:`record_to_message` 把 AiMessageRecord(单行)还原成 ChatMessage。
// 写路径(批次 B)备好但本批次不调:`message_to_record`(save_conversation 写 ai_messages 用)。
//
// round-trip 严格性:
// - role 枚举 ↔ 小写字符串双向(serialize rename_all=lowercase)
// - parts / tool_calls:JSON 字符串 ↔ Option<Vec> 双向反序列化
// - status:None ↔ "active"(对齐 sanitize is_active 归一化)
// - id:record.id 必非空(主键),还原为 ChatMessage.id=Some;
// 反向 ChatMessage.id=None 时兜底 `msg_{conv}_{seq}`(save 写路径用,本批次不触发)
//
// 映射工具放 src-tauri(非 df-ai-core):df-ai-core 不依赖 df-storage(防循环依赖,
// df-storage 的迁移层注释明确不依赖 df-ai-core),只能在上层 src-tauri 桥接两者类型。
/// role 字符串 → MessageRole 枚举(小写,对齐 serialize rename_all="lowercase")。
///
/// 未知值兜底 User(防御性,实际 DB role 列由 message_to_record 写入,值域受控)。
fn role_from_str(s: &str) -> MessageRole {
match s {
"system" => MessageRole::System,
"user" => MessageRole::User,
"assistant" => MessageRole::Assistant,
"tool" => MessageRole::Tool,
_ => MessageRole::User,
}
}
/// MessageRole 枚举 → role 字符串(小写)。
fn role_to_str(r: &MessageRole) -> &'static str {
match r {
MessageRole::System => "system",
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
MessageRole::Tool => "tool",
}
}
/// AiMessageRecord → ChatMessage(读路径核心:list_by_conversation 还原消息)。
///
/// - role 字符串 → 枚举
/// - parts/tool_calls:JSON 字符串 → Vec 反序列化(空/解析失败 → None)
/// - status:"active" → None(对齐 sanitize is_active 归一化),其他原样保留
/// - id:record.id 必非空(主键)→ Some
pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage {
let parts = rec
.parts
.as_deref()
.filter(|s| !s.is_empty())
.and_then(|s| serde_json::from_str(s).ok());
let tool_calls = rec
.tool_calls
.as_deref()
.filter(|s| !s.is_empty())
.and_then(|s| serde_json::from_str(s).ok());
// status "active" → None(归一化,对齐 ChatMessage 默认语义 None=正常可见)
let status = if rec.status == "active" {
None
} else {
Some(rec.status.clone())
};
ChatMessage {
id: Some(rec.id.clone()),
role: role_from_str(&rec.role),
content: rec.content.clone(),
parts,
tool_call_id: rec.tool_call_id.clone(),
tool_calls,
model: rec.model.clone(),
status,
reasoning_content: rec.reasoning_content.clone(),
timestamp: rec.timestamp,
}
}
/// ChatMessage → AiMessageRecord(写路径:save_conversation 全量重写 ai_messages 用)。
///
/// 调用方传入 conversation_id + seq(对话内序号,按索引)。
/// - id None 兜底:`msg_{conv}_{seq}`(老数据无 id 时,落库时补主键)
/// - role 枚举 → 小写字符串
/// - parts/tool_calls → JSON 字符串
/// - status None → "active"(归一化,对齐 record 默认列值)
/// - created_at 调用方传入(save_conversation 用对话 created_at 或 now)
pub fn message_to_record(
msg: &ChatMessage,
conversation_id: &str,
seq: i64,
created_at: &str,
) -> AiMessageRecord {
let id = msg.id.clone().unwrap_or_else(|| format!("msg_{}_{}", conversation_id, seq));
let parts = msg
.parts
.as_ref()
.map(|p| serde_json::to_string(p).unwrap_or_default());
let tool_calls = msg
.tool_calls
.as_ref()
.map(|t| serde_json::to_string(t).unwrap_or_default());
let status = msg.status.clone().unwrap_or_else(|| "active".to_string());
AiMessageRecord {
id,
conversation_id: conversation_id.to_string(),
seq,
role: role_to_str(&msg.role).to_string(),
content: msg.content.clone(),
parts,
tool_call_id: msg.tool_call_id.clone(),
tool_calls,
model: msg.model.clone(),
status,
reasoning_content: msg.reasoning_content.clone(),
timestamp: msg.timestamp,
created_at: created_at.to_string(),
}
}
// ============================================================
// 对话管理
// ============================================================
@@ -61,7 +180,7 @@ pub async fn ai_conversation_create(
};
if let Some(ref oc) = old_conv {
if old_has_msgs {
save_conversation(&state.ai_session, &state.db, oc.as_str(), None, None).await;
save_conversation(&state.ai_session, &state.db, oc.as_str(), None, None, true).await;
}
}
@@ -130,10 +249,36 @@ pub async fn ai_conversation_switch(
.map_err(err_str)?
.ok_or_else(|| format!("对话不存在: {}", conversation_id))?;
let messages: Vec<ChatMessage> = serde_json::from_str(&record.messages)
.map_err(|e| format!("解析消息失败: {}", e))?;
// F-260619-03 消息拆分存储(批次 A 读路径):优先从 ai_messages 表加载(每条消息一行),
// 替代旧 ai_conversations.messages 整对话 JSON 反序列化。映射 Vec<AiMessageRecord>
// → Vec<ChatMessage> → restore_from_messages。
//
// fallback 兜底:ai_messages 表为空(返空 Vec)但旧 messages JSON 列非空 `[]`
// (老库未迁移 / 坏数据 / 批次 B 写路径尚未上线时的新对话)→ 回退读 messages JSON + warn。
// 双向兼容:批次 B 上线后写双轨,读永远先走 ai_messages;迁移未跑的老对话走 fallback。
let records = state.ai_messages.list_by_conversation(&conversation_id).await
.map_err(err_str)?;
let messages: Vec<ChatMessage> = if !records.is_empty() {
records.iter().map(record_to_message).collect()
} else {
// 表空 → fallback 旧 messages JSON 列(若也空则空 Vec,空对话合法)
let has_legacy = record.messages != "[]" && !record.messages.is_empty();
if has_legacy {
tracing::warn!(
"ai_messages 表为空但旧 messages JSON 非空,回退读 JSON 列(conv_id={}): \
老库未迁移或批次 B 写路径未上线",
conversation_id
);
serde_json::from_str(&record.messages)
.map_err(|e| format!("解析消息失败: {}", e))?
} else {
Vec::new()
}
};
let messages_json = record.messages.clone();
// 由 records/chat_messages 重序列化返回前端(前端契约不变,仍吃 JSON 字符串)。
let messages_json = serde_json::to_string(&messages)
.map_err(|e| format!("序列化消息失败: {}", e))?;
let title = record.title.clone();
// B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求)。
// 含 "新对话" 占位(Some 但未生成):title.rs ensure :40 同步排除"新对话"占位不跳过,
@@ -308,8 +453,22 @@ pub async fn ai_conversation_export(
.map_err(err_str)?
.ok_or_else(|| format!("对话不存在: {}", conversation_id))?;
let messages: Vec<ChatMessage> = serde_json::from_str(&record.messages)
.map_err(|e| format!("解析消息失败: {}", e))?;
// F-260619-03 批次 A:导出读路径同样切读 ai_messages 表(与 switch 一致),
// fallback 旧 messages JSON 列(老库未迁移/坏数据)。空对话 → 空 messages。
let records = state.ai_messages.list_by_conversation(&conversation_id).await
.map_err(err_str)?;
let messages: Vec<ChatMessage> = if !records.is_empty() {
records.iter().map(record_to_message).collect()
} else if record.messages != "[]" && !record.messages.is_empty() {
tracing::warn!(
"导出:ai_messages 表为空但旧 messages JSON 非空,回退读 JSON 列(conv_id={})",
conversation_id
);
serde_json::from_str(&record.messages)
.map_err(|e| format!("解析消息失败: {}", e))?
} else {
Vec::new()
};
let body = match fmt {
"markdown" => {
@@ -352,3 +511,377 @@ pub async fn ai_conversation_export(
Ok(body)
}
// ============================================================
// 测试:ChatMessage ↔ AiMessageRecord 映射 round-trip
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use df_ai::provider::{ChatMessage, ContentPart, MessageRole, ToolCall, ToolCallFunction};
fn base_msg() -> ChatMessage {
ChatMessage {
id: Some("msg_test_1".into()),
role: MessageRole::User,
content: "你好".into(),
parts: None,
tool_call_id: None,
tool_calls: None,
model: None,
status: None,
reasoning_content: None,
timestamp: Some(1700000000000),
}
}
/// 纯文本消息 round-trip:status None ↔ "active" 归一化
#[test]
fn roundtrip_plain_text_status_none() {
let msg = base_msg();
let rec = message_to_record(&msg, "conv_x", 0, "2026-01-01T00:00:00Z");
assert_eq!(rec.id, "msg_test_1");
assert_eq!(rec.conversation_id, "conv_x");
assert_eq!(rec.seq, 0);
assert_eq!(rec.role, "user");
assert_eq!(rec.status, "active", "None 应归一化为 active");
assert!(rec.parts.is_none());
assert!(rec.tool_calls.is_none());
let back = record_to_message(&rec);
assert_eq!(back.id.as_deref(), Some("msg_test_1"));
assert_eq!(back.status, None, "active 应还原为 None");
assert_eq!(back.content, "你好");
assert_eq!(back.timestamp, Some(1700000000000));
assert!(matches!(back.role, MessageRole::User));
}
/// 多模态消息(parts 含 Image base64)round-trip
#[test]
fn roundtrip_multimodal_parts() {
let mut msg = base_msg();
msg.parts = Some(vec![
ContentPart::Text { text: "看这张图".into() },
ContentPart::Image {
url: None,
base64: Some("iVBORw0KGgo=".into()),
media_type: Some("image/png".into()),
alt: None,
},
]);
let rec = message_to_record(&msg, "conv_m", 1, "ts");
// parts 应序列化成 JSON 字符串
assert!(rec.parts.is_some());
let back = record_to_message(&rec);
assert_eq!(back.parts, msg.parts, "parts Image base64 双向一致");
}
/// assistant 工具调用消息(tool_calls 多条)round-trip
#[test]
fn roundtrip_tool_calls() {
let mut msg = base_msg();
msg.role = MessageRole::Assistant;
msg.model = Some("deepseek-chat".into());
msg.tool_calls = Some(vec![
ToolCall {
id: "call_1".into(),
call_type: "function".into(),
function: ToolCallFunction {
name: "read_file".into(),
arguments: r#"{"path":"a.rs"}"#.into(),
},
},
ToolCall {
id: "call_2".into(),
call_type: "function".into(),
function: ToolCallFunction {
name: "write_file".into(),
arguments: r#"{"path":"b.rs"}"#.into(),
},
},
]);
let rec = message_to_record(&msg, "conv_t", 2, "ts");
assert_eq!(rec.role, "assistant");
assert_eq!(rec.model.as_deref(), Some("deepseek-chat"));
assert!(rec.tool_calls.is_some());
let back = record_to_message(&rec);
assert_eq!(back.tool_calls.as_ref().map(|v| v.len()), Some(2));
// ToolCall 无 PartialEq,经 JSON 字符串比对(round-trip 一致)
assert_eq!(
serde_json::to_string(&back.tool_calls).unwrap(),
serde_json::to_string(&msg.tool_calls).unwrap(),
"tool_calls 多条双向一致"
);
assert_eq!(back.model.as_deref(), Some("deepseek-chat"));
}
/// tool 消息(tool_call_id)round-trip
#[test]
fn roundtrip_tool_message() {
let mut msg = base_msg();
msg.role = MessageRole::Tool;
msg.content = "文件内容...".into();
msg.tool_call_id = Some("call_abc".into());
let rec = message_to_record(&msg, "conv_tool", 3, "ts");
assert_eq!(rec.role, "tool");
assert_eq!(rec.tool_call_id.as_deref(), Some("call_abc"));
let back = record_to_message(&rec);
assert!(matches!(back.role, MessageRole::Tool));
assert_eq!(back.tool_call_id.as_deref(), Some("call_abc"));
}
/// status 各值("truncated"/"compressed")round-trip(非 active 原样保留)
#[test]
fn roundtrip_status_non_active_preserved() {
let mut msg = base_msg();
msg.status = Some("truncated".into());
let rec = message_to_record(&msg, "c", 0, "ts");
assert_eq!(rec.status, "truncated");
let back = record_to_message(&rec);
assert_eq!(back.status.as_deref(), Some("truncated"));
// compressed
msg.status = Some("compressed".into());
let rec = message_to_record(&msg, "c", 0, "ts");
assert_eq!(rec.status, "compressed");
}
/// id=None 兜底:`msg_{conv}_{seq}`
#[test]
fn roundtrip_id_none_fallback() {
let mut msg = base_msg();
msg.id = None;
let rec = message_to_record(&msg, "conv_y", 5, "ts");
assert_eq!(rec.id, "msg_conv_y_5", "id None 应兜底 msg_conv_seq");
// 读回:id 来自 record.id(非空)→ Some
let back = record_to_message(&rec);
assert_eq!(back.id.as_deref(), Some("msg_conv_y_5"));
}
/// reasoning_content round-trip
#[test]
fn roundtrip_reasoning_content() {
let mut msg = base_msg();
msg.role = MessageRole::Assistant;
msg.reasoning_content = Some("思考过程...".into());
let rec = message_to_record(&msg, "c", 0, "ts");
assert_eq!(rec.reasoning_content.as_deref(), Some("思考过程..."));
let back = record_to_message(&rec);
assert_eq!(back.reasoning_content.as_deref(), Some("思考过程..."));
}
/// 完整多字段混合 round-trip(覆盖全字段一致性)
#[test]
fn roundtrip_full_fields() {
let mut msg = base_msg();
msg.role = MessageRole::Assistant;
msg.content = "结果".into();
msg.parts = Some(vec![ContentPart::Text { text: "t".into() }]);
msg.tool_calls = Some(vec![ToolCall {
id: "c1".into(),
call_type: "function".into(),
function: ToolCallFunction {
name: "f".into(),
arguments: "{}".into(),
},
}]);
msg.model = Some("m".into());
msg.status = Some("compressed".into());
msg.reasoning_content = Some("r".into());
msg.timestamp = Some(123);
let rec = message_to_record(&msg, "conv_full", 7, "ts_full");
let back = record_to_message(&rec);
assert_eq!(back.id.as_deref(), Some("msg_test_1"));
assert!(matches!(back.role, MessageRole::Assistant));
assert_eq!(back.content, "结果");
assert_eq!(back.parts, msg.parts);
assert_eq!(
serde_json::to_string(&back.tool_calls).unwrap(),
serde_json::to_string(&msg.tool_calls).unwrap()
);
assert_eq!(back.model.as_deref(), Some("m"));
assert_eq!(back.status.as_deref(), Some("compressed"));
assert_eq!(back.reasoning_content.as_deref(), Some("r"));
assert_eq!(back.timestamp, Some(123));
}
// ============================================================
// F-260619-03 批次 B 端到端 round-trip(写路径全链:映射 → replace → list → 还原)
// ============================================================
//
// 覆盖 save_conversation 写路径核心链路(不依赖 AiSession 夹具):
// Vec<ChatMessage> --message_to_record--> Vec<AiMessageRecord>
// --replace_conversation--> ai_messages 表
// --list_by_conversation--> Vec<AiMessageRecord>
// --record_to_message--> Vec<ChatMessage>
// 验证:长对话 50+ 轮全字段(parts/tool_calls/status/id/timestamp/reasoning_content)一致。
#[tokio::test]
async fn batch_b_save_load_roundtrip_50_rounds_full_fields() {
use df_storage::crud::AiMessageRepo;
use df_storage::db::Database;
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiMessageRepo::new(&db);
// 构造 50 轮 user/assistant/tool 混合消息(共 150 条),覆盖全字段
let conv_id = "conv_rt";
let created_at = "2026-06-20T00:00:00Z";
let original: Vec<ChatMessage> = (0..150)
.map(|i| {
let role = i % 3;
let mut m = match role {
0 => ChatMessage {
id: Some(format!("u_{i}")),
role: MessageRole::User,
content: format!("用户提问 {i},带中文"),
parts: Some(vec![ContentPart::Text { text: format!("片 {i}") }]),
timestamp: Some(1_700_000_000_000 + i),
..base_msg()
},
1 => ChatMessage {
id: Some(format!("a_{i}")),
role: MessageRole::Assistant,
content: format!("助手回答 {i}"),
model: Some("deepseek-chat".into()),
reasoning_content: Some(format!("思考 {i}")),
tool_calls: Some(vec![ToolCall {
id: format!("call_{i}"),
call_type: "function".into(),
function: ToolCallFunction {
name: "read_file".into(),
arguments: format!(r#"{{"path":"{i}.rs"}}"#),
},
}]),
status: if i % 6 == 0 {
Some("compressed".into())
} else {
None
},
timestamp: Some(1_700_000_000_000 + i),
..base_msg()
},
_ => ChatMessage {
id: Some(format!("t_{i}")),
role: MessageRole::Tool,
content: format!("工具结果 {i}:大段内容"),
tool_call_id: Some(format!("call_{}", i - 1)),
timestamp: Some(1_700_000_000_000 + i),
..base_msg()
},
};
m.id = Some(format!("msg_{conv_id}_{i}"));
m
})
.collect();
// 写路径:映射 + replace_conversation(全量重写)
let records: Vec<_> = original
.iter()
.enumerate()
.map(|(seq, m)| message_to_record(m, conv_id, seq as i64, created_at))
.collect();
repo.replace_conversation(conv_id, records)
.await
.expect("replace");
// 读路径:list + 还原
let got_records = repo.list_by_conversation(conv_id).await.expect("list");
assert_eq!(got_records.len(), 150, "应读回全部 150 条");
let restored: Vec<ChatMessage> =
got_records.iter().map(record_to_message).collect();
// 全字段逐一比对
assert_eq!(restored.len(), original.len());
for (i, (orig, back)) in original.iter().zip(restored.iter()).enumerate() {
assert_eq!(back.id, orig.id, "id 不一致 @ {i}");
// role 枚举比对(MessageRole 非 Copy,用 std::mem::discriminant 判变体相等)
assert_eq!(
std::mem::discriminant(&back.role),
std::mem::discriminant(&orig.role),
"role 不一致 @ {i}"
);
assert_eq!(back.content, orig.content, "content 不一致 @ {i}");
assert_eq!(back.parts, orig.parts, "parts 不一致 @ {i}");
assert_eq!(back.tool_call_id, orig.tool_call_id, "tool_call_id 不一致 @ {i}");
// tool_calls 无 PartialEq → JSON 比对
assert_eq!(
serde_json::to_string(&back.tool_calls).unwrap(),
serde_json::to_string(&orig.tool_calls).unwrap(),
"tool_calls 不一致 @ {i}"
);
assert_eq!(back.model, orig.model, "model 不一致 @ {i}");
assert_eq!(back.status, orig.status, "status 不一致 @ {i}");
assert_eq!(
back.reasoning_content, orig.reasoning_content,
"reasoning_content 不一致 @ {i}"
);
assert_eq!(back.timestamp, orig.timestamp, "timestamp 不一致 @ {i}");
}
// seq 顺序校验
for (i, rec) in got_records.iter().enumerate() {
assert_eq!(rec.seq, i as i64, "seq 应连续递增 @ {i}");
assert_eq!(rec.conversation_id, conv_id, "conversation_id 应一致 @ {i}");
}
}
/// save 全量重写后再次 save 变更内容(模拟 compress/edit 后下轮 save 覆盖)
#[tokio::test]
async fn batch_b_save_overwrite_reflects_inmemory_change() {
use df_storage::crud::AiMessageRepo;
use df_storage::db::Database;
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiMessageRepo::new(&db);
let conv_id = "conv_overwrite";
let created_at = "ts";
// 第一轮:3 条消息
let v1: Vec<ChatMessage> = (0..3)
.map(|i| ChatMessage {
id: Some(format!("m_{i}")),
role: MessageRole::User,
content: format!("v1_{i}"),
..base_msg()
})
.collect();
let recs: Vec<_> = v1
.iter()
.enumerate()
.map(|(s, m)| message_to_record(m, conv_id, s as i64, created_at))
.collect();
repo.replace_conversation(conv_id, recs).await.expect("save v1");
// 第二轮:内存变化——压缩成 1 条(status=compressed)+ 删除 2 条 + 新增 1 条
let v2: Vec<ChatMessage> = vec![
ChatMessage {
id: Some("m_summary".into()),
role: MessageRole::Assistant,
content: "压缩摘要".into(),
status: Some("compressed".into()),
..base_msg()
},
ChatMessage {
id: Some("m_new".into()),
role: MessageRole::User,
content: "压缩后新提问".into(),
..base_msg()
},
];
let recs2: Vec<_> = v2
.iter()
.enumerate()
.map(|(s, m)| message_to_record(m, conv_id, s as i64, created_at))
.collect();
repo.replace_conversation(conv_id, recs2).await.expect("save v2");
// 读回:应完全是 v2,v1 的 3 条已删
let got = repo.list_by_conversation(conv_id).await.expect("list");
assert_eq!(got.len(), 2, "v2 全量重写后应只剩 2 条");
assert_eq!(got[0].id, "m_summary");
assert_eq!(got[0].status, "compressed");
assert_eq!(got[1].id, "m_new");
assert_eq!(got[1].content, "压缩后新提问");
}
}

View File

@@ -9,6 +9,7 @@ use df_storage::db::Database;
use crate::commands::now_millis;
use super::commands::message_to_record;
use super::AiSession;
/// Token 用量累加器(agent loop 生命周期内各轮叠加)
@@ -130,15 +131,22 @@ pub(crate) fn truncate_parts_for_persist(parts: &[df_ai::provider::ContentPart])
/// 保存对话到数据库(按 conv_id 写库,不受 active_conversation_id 切换影响)
///
/// 写 messages + updated_at + 累加 token 用量 + 首次落库的 model标题由 ensure_conversation_title 单独生成。
/// 写 ai_messages(每条消息一行,F-260619-03 拆分存储)+ updated_at + 累加 token 用量 +
/// 首次落库的 model;标题由 ensure_conversation_title 单独生成。
/// token 走累加模式:upsert 读旧值叠加,保证审批暂停→恢复跨 loop 实例不覆盖丢失。
/// model 仅首次落库写入 + 旧记录缺值时补填(不覆盖历史已存值,兼容本次改造前的老对话)。
///
/// F-260619-03 批次 B:写路径切 ai_messages(全量重写,replace_conversation)。
/// 内存 ContextManager 是运行时真相源,save 是同步点——每轮全量回写 ai_messages,
/// compress/replace/edit/clear_context 内存改 status/content 后下轮 save 自动覆盖。
/// 旧 messages JSON 列**保留不写**(作备份,防 reload fallback 读旧脏数据),不赋新值也不置空。
pub(crate) async fn save_conversation(
session_arc: &Arc<Mutex<AiSession>>,
db: &Arc<Database>,
conv_id: &str,
usage: Option<&df_ai::provider::TokenUsage>,
model: Option<&str>,
touch_updated_at: bool,
) {
// 取 messages + 懒创建首次落库所需的 provider_id/created_at
// 工具结果(content)超 50KB 时截断头尾各 ~20KB + 中段标注,防大体量结果(read_file 1MB 洞 /
@@ -149,7 +157,7 @@ pub(crate) async fn save_conversation(
// loop 内 save 由 run_agentic_loop 入参 conv_id 透传;IPC 路径(commands.rs)save 也传 conv_id。
// conv() 惰性建:save 路径 conv 必然已建(send/regenerate/edit/switch 均先 conv());若极端
// 未建(如启动恢复无 live conv),conv() 建空 PerConvState,save 空 messages(幂等不污染)。
let (messages_json, provider_id, created_at) = {
let (persist_msgs, provider_id, created_at) = {
let mut session = session_arc.lock().await;
let mut msgs = session.conv(conv_id).messages.all_messages_clone();
for m in &mut msgs {
@@ -161,18 +169,34 @@ pub(crate) async fn save_conversation(
}
}
(
serde_json::to_string(&msgs).unwrap_or_else(|_| "[]".to_string()),
msgs,
session.active_provider_id.clone(),
session.active_conv_created_at.clone(),
)
};
// F-260619-03 批次 B:映射 Vec<ChatMessage> → Vec<AiMessageRecord>(带 seq 索引 + conv_id)
// 全量重写 ai_messages(单事务 DELETE + INSERT OR IGNORE,原子无中间空窗)。
// created_at 用对话级 created_at(老对话 None 时 now 兜底),保证消息创建时间与对话一致。
let now = now_millis();
let msg_created_at = created_at.clone().unwrap_or_else(|| now.clone());
let records: Vec<df_storage::models::AiMessageRecord> = persist_msgs
.iter()
.enumerate()
.map(|(seq, m)| message_to_record(m, conv_id, seq as i64, &msg_created_at))
.collect();
let conv_repo = AiConversationRepo::new(db);
let msg_repo = df_storage::crud::AiMessageRepo::new(db);
match conv_repo.get_by_id(conv_id).await {
Ok(Some(mut rec)) => {
// 已落库:更新 messages + updated_at;token 累加(读旧值+新值,跨 loop 实例防覆盖)
rec.messages = messages_json;
rec.updated_at = now_millis();
// 已落库:更新对话元数据(token/model/updated_at);messages JSON 列**不赋新值**(保留旧值作备份)。
// updated_at 按 touch_updated_at 条件改(用户活跃=true 反映最后活跃,
// 系统摘要/压缩=false 防会话时间分组"昨天→今天"跳变);
// token 累加(读旧值+新值,跨 loop 实例防覆盖)。
if touch_updated_at {
rec.updated_at = now_millis();
}
if let Some(u) = usage {
rec.prompt_tokens = accumulate_tokens(rec.prompt_tokens, u.prompt_tokens);
rec.completion_tokens = accumulate_tokens(rec.completion_tokens, u.completion_tokens);
@@ -187,13 +211,21 @@ pub(crate) async fn save_conversation(
if !list.iter().any(|x| x == m) { list.push(m.to_string()); }
rec.models = Some(serde_json::to_string(&list).unwrap_or_else(|_| "[]".to_string()));
}
// update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at
if let Err(e) = conv_repo.update_full(&rec).await {
tracing::warn!("更新对话失败 {conv_id}: {e}");
tracing::warn!("更新对话元数据失败 {conv_id}: {e}");
}
// 消息拆分存储:全量重写 ai_messages
if let Err(e) = msg_repo.replace_conversation(conv_id, records).await {
tracing::warn!("全量重写 ai_messages 失败 {conv_id}: {e}");
}
}
Ok(None) => {
// 懒创建首次落库(此为空对话不落库的落库点:走到这里 messages 必非空)
let now = now_millis();
// messages JSON 列首次落库也写(兼容未跑迁移的老库 fallback 读路径),
// 同时写 ai_messages(新读路径真相源)。
let messages_json = serde_json::to_string(&persist_msgs).unwrap_or_else(|_| "[]".to_string());
let conv_created = created_at.unwrap_or_else(|| now.clone());
let rec = df_storage::models::AiConversationRecord {
id: conv_id.to_string(),
title: None,
@@ -205,12 +237,17 @@ pub(crate) async fn save_conversation(
pinned: false,
prompt_tokens: usage.map(|u| u.prompt_tokens as i64),
completion_tokens: usage.map(|u| u.completion_tokens as i64),
created_at: created_at.unwrap_or_else(|| now.clone()),
created_at: conv_created.clone(),
updated_at: now,
};
if let Err(e) = conv_repo.insert(rec).await {
tracing::warn!("落库对话失败 {conv_id}: {e}");
}
// 消息拆分存储:首次落库同样全量写 ai_messages
// (records 已用 conv_created 作 created_at,与对话记录一致)
if let Err(e) = msg_repo.replace_conversation(conv_id, records).await {
tracing::warn!("首次写 ai_messages 失败 {conv_id}: {e}");
}
}
Err(e) => tracing::warn!("读取对话 {conv_id} 失败: {e}"),
}

View File

@@ -68,7 +68,12 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
- Briefly explain your intent before executing actions\n\
- Ask for clarification if the user's intent is unclear\n\
- Prefer using tools to complete actions rather than just describing steps\n\
- When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n",
- When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n\
## Focus\n\
- Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\
- When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\
- Give the conclusion or action first, then add only necessary explanation; omit tangential information unrelated to the current request.\n\
- Do not proactively expand context (files/data) that is irrelevant to the current request.\n",
"\n## Current Projects\n",
"\n## Current Tasks\n",
),
@@ -86,7 +91,12 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
- 执行操作前简要说明你的意图\n\
- 如果不确定用户意图,先提问\n\
- 优先使用工具完成操作,而不是只描述步骤\n\
- 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n",
- 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\
## 聚焦准则\n\
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
- 回答先给结论/动作,再补必要的解释;与当前请求无关的扩展信息省略。\n\
- 需要的上下文(文件/数据)若与当前请求无关,不要主动展开。\n",
"\n## 当前项目\n",
"\n## 当前任务\n",
),
@@ -168,6 +178,9 @@ pub(crate) fn compress_prompt(lang: &str) -> &'static str {
- 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\
- Must preserve core topic words, entity names, and technical terms the user \
repeatedly mentions; they are anchors for continuing the conversation and losing \
them breaks the context thread.\n\
- Do NOT invent facts not present in the conversation.\n\
- Output the four sections only, no preamble or extra commentary.",
_ => "你是对话总结器。请把以下对话压缩为结构化摘要,保留继续推进工作所必需的关键上下文。\
@@ -189,7 +202,84 @@ pub(crate) fn compress_prompt(lang: &str) -> &'static str {
- 简洁,优先用要点。\n\
- 去掉寒暄、过渡性客套,只保留技术上有价值的事实。\n\
- 文件路径、标识符、错误信息等照原样保留。\n\
- 必须保留用户反复提及的核心主题词、实体名、技术名词(它们是对话续接的锚点,丢失会致上下文断裂)。\n\
- 不要编造对话中没有的事实。\n\
- 只输出上述四段内容,不要前言、解释或额外评论。",
}
}
#[cfg(test)]
mod tests {
use super::*;
// 改进1 系统提示聚焦段:中文版含「聚焦准则」独立段
#[test]
fn system_prompt_zh_has_focus_section() {
let (prefix, _, _) = system_prompt_parts("zh-CN");
assert!(
prefix.contains("聚焦准则"),
"中文 system prompt 应含聚焦准则段,实际: {}",
prefix
);
// 独立段标题,非稀释在行为准则里
assert!(prefix.contains("## 聚焦准则"));
// 核心条款抽样
assert!(prefix.contains("始终围绕用户当前请求的核心目标"));
assert!(prefix.contains("也绝不谎报成功"));
}
// 改进1 系统提示聚焦段:英文版含「Focus」独立段
#[test]
fn system_prompt_en_has_focus_section() {
let (prefix, _, _) = system_prompt_parts("en");
assert!(
prefix.contains("Focus"),
"英文 system prompt 应含 Focus 段,实际: {}",
prefix
);
assert!(prefix.contains("## Focus"));
assert!(prefix.contains("never falsely report success"));
assert!(prefix.contains("core goal of the user's current request"));
}
// 兜底:lang 未匹配回落中文,聚焦段仍存在
#[test]
fn system_prompt_unknown_lang_falls_back_zh_with_focus() {
let (prefix, _, _) = system_prompt_parts("fr");
assert!(prefix.contains("聚焦准则"));
}
#[test]
fn compress_prompt_unaffected_by_focus_addition() {
// 压缩 prompt 是独立函数,聚焦段改动不应波及
let zh = compress_prompt("zh-CN");
assert!(zh.contains("意图"));
let en = compress_prompt("en");
assert!(en.contains("Intent"));
}
// 改进3 A: 压缩 prompt 主题保留段(锚点词防上下文断裂)
#[test]
fn compress_prompt_zh_keeps_topic_anchor_clause() {
let zh = compress_prompt("zh-CN");
assert!(
zh.contains("主题词"),
"中文 compress_prompt 应含主题词/锚点保留要求,实际: {}",
zh
);
assert!(zh.contains("锚点"));
assert!(zh.contains("上下文断裂"));
}
#[test]
fn compress_prompt_en_keeps_topic_anchor_clause() {
let en = compress_prompt("en");
assert!(
en.contains("anchors"),
"英文 compress_prompt 应含 anchors 保留要求,实际: {}",
en
);
assert!(en.contains("topic words"));
assert!(en.contains("breaks the context"));
}
}

View File

@@ -177,7 +177,15 @@ async fn generate_title_via_llm(
// F-09 B 批5: per_conv 改 HashMap<conv_id>,标题针对本对话,用 conv_id 共享限流槽。
let _global_permit = llm_concurrency.acquire_global().await;
let _per_conv_permit = llm_concurrency.acquire_per_conv(conv_id).await;
let resp = provider.complete(request).await.ok()?;
let resp = match provider.complete(request).await {
Ok(r) => r,
Err(e) => {
// 诊断:标题 LLM complete 失败原因(网络/模型/超时)。ensure :121 防御已保留 extract 兜底,
// 此日志助定位"为何标题未 LLM 精炼生成"(complete 失败 vs resp.text 空走 clean 兜底"新对话")。
tracing::warn!("标题 LLM complete 失败(conv_id={}, model={}): {}", conv_id, model, e);
return None;
}
};
Some(clean_title(&resp.text))
}

View File

@@ -11,9 +11,9 @@ use tokio::sync::{Mutex, RwLock, Semaphore};
use df_ai::ai_tools::AiToolRegistry;
use df_storage::crud::{
AiConversationRepo, AiProviderRepo, AiToolExecutionRepo, IdeaRepo, KnowledgeEventsRepo,
KnowledgeRepo, NodeExecutionRepo, ProjectRepo, ReleaseRepo, SettingsRepo, TaskRepo,
WorkflowRepo,
AiConversationRepo, AiMessageRepo, AiProviderRepo, AiToolExecutionRepo, IdeaRepo,
KnowledgeEventsRepo, KnowledgeRepo, NodeExecutionRepo, ProjectRepo, ReleaseRepo,
SettingsRepo, TaskRepo, WorkflowRepo,
};
use df_storage::db::Database;
use df_workflow::eventbus::EventBus;
@@ -261,6 +261,8 @@ pub struct AppState {
pub ai_providers: AiProviderRepo,
/// AI 对话历史 Repo
pub ai_conversations: AiConversationRepo,
/// AI 消息 Repo(F-260619-03 消息拆分存储:ai_messages 表,读路径批次 A 切读用)
pub ai_messages: AiMessageRepo,
/// AI 工具执行审计 Repo
pub ai_tool_executions: AiToolExecutionRepo,
/// AI 工具注册表
@@ -484,6 +486,7 @@ impl AppState {
node_executions: NodeExecutionRepo::new(&db),
ai_providers: AiProviderRepo::new(&db),
ai_conversations: AiConversationRepo::new(&db),
ai_messages: AiMessageRepo::new(&db),
ai_tool_executions: AiToolExecutionRepo::new(&db),
ai_session: Arc::new(Mutex::new(AiSession::new())),
knowledge: KnowledgeRepo::new(&db),