新增: 跨会话召回四层架构与脉络卡
- 会话终态提炼: 删除前+停靠双挂点,快照与清水位同临界区 - 目标快照: 恢复引导按最终有效意图生成,旧目标不复活 - 知识检索项目维度软排序,WorkingContext 持久化迁移 - 脉络卡: published 知识按项目聚合纯读侧,带入上下文经输入框显式注入 - 压缩与缓存能力在主循环/子代理/提示词三面接线 - 两份专项设计文档与看板销账
This commit is contained in:
@@ -11,9 +11,12 @@
|
||||
//! ②knowledge_promote 显式转正(对话内人工/agent 拍板,仅 candidate 可转,与自动晋升
|
||||
//! 互不影响)。insert 只负责落候选,转正须显式调用 promote,不存在隐式发布。
|
||||
//!
|
||||
//! 溯源说明:工具 handler 只有 args 入参,无会话上下文注入机制,拿不到 conv_id,
|
||||
//! 故 source_ref 记渠道标记 "ai-chat" 兜底(标识写入来源是 AI 对话,精确到会话待
|
||||
//! 上下文注入机制具备后补全)。
|
||||
//! 会话上下文通道:handler 仍只有 args 入参,会话 id 由执行链注入——所有工具执行
|
||||
//! (process_tool_calls 与审批/授权窗口)统一经 execute_with_heartbeat 包
|
||||
//! spawn_agent::scope_caller_conv,handler 内 caller_conv() 读取。search 消费:
|
||||
//! 解析当前会话归属项目接三档软排序,无 scope(直调)或归属推断失败降级全局检索;
|
||||
//! insert 消费:source_ref 记 conv:{id} 与自动提炼产物同格式,归属项目同口径推断,
|
||||
//! 无 scope 降级渠道标记 "ai-chat" 兜底溯源。
|
||||
//!
|
||||
//! kind 默认值:任务草案默认 "fact",但 knowledges 表合法 kind 是 7 种 KnowledgeKind
|
||||
//! snake_case(review_rule/prompt_template/pitfall/architecture_pattern/diagnosis/
|
||||
@@ -31,7 +34,8 @@ use df_storage::models::KnowledgeRecord;
|
||||
use df_types::types::new_id;
|
||||
|
||||
use crate::commands::ai::audit::truncate_chars_ellipsis;
|
||||
use crate::commands::ai::knowledge_inject::{extract_query_keywords, spawn_embedding_for_record};
|
||||
use crate::commands::ai::knowledge_inject::{extract_query_keywords, resolve_source_project, spawn_embedding_for_record};
|
||||
use crate::commands::ai::tools::spawn_agent::caller_conv;
|
||||
use crate::commands::knowledge_timeline::KnowledgeTimeline;
|
||||
use crate::commands::now_millis;
|
||||
use crate::state::load_knowledge_config;
|
||||
@@ -52,7 +56,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"knowledge_search",
|
||||
"检索本地知识库(仅已发布知识,query 自动分词提取关键词匹配,标题命中优先、复用次数降序)。使用时机:回答前需要过往经验/项目约定/踩坑记录时调用,如用户问「之前怎么解决的」「有没有相关约定」。用户说「记住/记一下/以后注意」时不要用本工具,改用 knowledge_insert。参数:query=知识主题关键词或自然语言描述(必填,自动分词),limit=返回条数(可选,默认 5,上限 10)",
|
||||
"检索本地知识库(仅已发布知识,query 自动分词提取关键词匹配,当前会话关联项目的知识排前,其后按标题命中优先、复用次数降序)。使用时机:回答前需要过往经验/项目约定/踩坑记录时调用,如用户问「之前怎么解决的」「有没有相关约定」。用户说「记住/记一下/以后注意」时不要用本工具,改用 knowledge_insert。参数:query=知识主题关键词或自然语言描述(必填,自动分词),limit=返回条数(可选,默认 5,上限 10)",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![("query", "string", true), ("limit", "integer", false)]),
|
||||
args => {
|
||||
@@ -66,7 +70,16 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
// 分词(ASCII 词 + 中文 2-gram)后逐词匹配,任一关键词命中即入候选。
|
||||
// 仅已发布(include_candidates=false):LLM 检索面与人工审核面一致。
|
||||
let keywords = extract_query_keywords(query);
|
||||
let hits = repo.search_keywords(&keywords, false, limit).await?;
|
||||
// 会话上下文通道(caller_conv)解析归属项目接三档软排序:同项目排前,全局与
|
||||
// 异项目仍可召回不淹没。软排序只改顺序不改命中面;无 scope(直调)或归属
|
||||
// 推断失败降级 project_id=None,与全局检索行为逐字一致,不报错。
|
||||
let current_project = match caller_conv() {
|
||||
Some(conv_id) => resolve_source_project(&db, &conv_id, &[]).await,
|
||||
None => None,
|
||||
};
|
||||
let hits = repo
|
||||
.search_keywords_scoped(&keywords, false, limit, current_project.as_deref())
|
||||
.await?;
|
||||
// 空结果返回可执行的下一步指引而非裸空数组:弱模型拿到 [] 常直接回「没找到」
|
||||
// 结束,带上「让用户口述 + insert 沉淀」能把检索失败转化为知识积累入口。
|
||||
if hits.is_empty() {
|
||||
@@ -123,6 +136,16 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
.collect();
|
||||
serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string())
|
||||
});
|
||||
// 写入归属(跨会话召回写入收口):会话上下文通道解析当前会话,source_ref 记
|
||||
// conv:{id} 与自动提炼产物同格式,归属项目同口径推断(boundary 级 a);
|
||||
// 无 scope(直调)降级渠道标记兜底溯源,与 search 无 scope 降级同风格。
|
||||
let (source_project, source_ref) = match caller_conv() {
|
||||
Some(conv_id) => (
|
||||
resolve_source_project(&db, &conv_id, &[]).await,
|
||||
Some(format!("conv:{}", conv_id)),
|
||||
),
|
||||
None => (None, Some("ai-chat".to_string())),
|
||||
};
|
||||
let now = now_millis();
|
||||
let record = KnowledgeRecord {
|
||||
id: new_id(),
|
||||
@@ -135,9 +158,8 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
confidence: None,
|
||||
reuse_count: 0,
|
||||
verified: false,
|
||||
source_project: None,
|
||||
// handler 无会话上下文,拿不到 conv_id,记渠道标记兜底溯源
|
||||
source_ref: Some("ai-chat".to_string()),
|
||||
source_project,
|
||||
source_ref,
|
||||
reasoning: None,
|
||||
embedding_status: None,
|
||||
created_at: now.clone(),
|
||||
@@ -204,3 +226,203 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::commands::ai::boundary::TaskBoundary;
|
||||
use crate::commands::ai::tools::spawn_agent::scope_caller_conv;
|
||||
use serde_json::json;
|
||||
|
||||
async fn test_db() -> Arc<Database> {
|
||||
Arc::new(Database::open_in_memory().await.unwrap())
|
||||
}
|
||||
|
||||
async fn ins_project(db: &Arc<Database>, id: &str, path: &str) {
|
||||
df_storage::crud::ProjectRepo::new(db)
|
||||
.insert(df_storage::models::ProjectRecord {
|
||||
id: id.into(),
|
||||
name: format!("项目-{id}"),
|
||||
description: String::new(),
|
||||
status: df_types::types::ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: Some(path.into()),
|
||||
stack: None,
|
||||
archived: false,
|
||||
created_at: "2026-01-01".into(),
|
||||
updated_at: "2026-01-01".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn ins_conv_with_boundary(db: &Arc<Database>, id: &str, dirs: &[&str]) {
|
||||
let boundary = TaskBoundary {
|
||||
risk_ceiling: RiskLevel::Medium,
|
||||
dir_allowlist: dirs.iter().map(|d| d.to_string()).collect(),
|
||||
forbid_destructive: true,
|
||||
budget: None,
|
||||
};
|
||||
df_storage::crud::AiConversationRepo::new(db)
|
||||
.insert(df_storage::models::AiConversationRecord {
|
||||
id: id.into(),
|
||||
title: Some("t".into()),
|
||||
messages: "[]".into(),
|
||||
provider_id: None,
|
||||
model: None,
|
||||
models: None,
|
||||
archived: false,
|
||||
pinned: false,
|
||||
prompt_tokens: None,
|
||||
completion_tokens: None,
|
||||
pinned_goals: None,
|
||||
pending_approvals: None,
|
||||
created_at: "2026-01-01".into(),
|
||||
updated_at: "2026-01-01".into(),
|
||||
origin: None,
|
||||
boundary_json: Some(serde_json::to_string(&boundary).unwrap()),
|
||||
loop_state: None,
|
||||
status: "active".into(),
|
||||
working_context_json: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn ins_knowledge(db: &Arc<Database>, title: &str, project: Option<&str>, reuse: i32) {
|
||||
let now = now_millis();
|
||||
df_storage::crud::KnowledgeRepo::new(db)
|
||||
.insert(KnowledgeRecord {
|
||||
id: new_id(),
|
||||
kind: "pitfall".into(),
|
||||
title: title.into(),
|
||||
content: format!("{title} 正文"),
|
||||
tags: None,
|
||||
status: "published".into(),
|
||||
confidence: None,
|
||||
reuse_count: reuse,
|
||||
verified: false,
|
||||
source_project: project.map(|p| p.into()),
|
||||
source_ref: None,
|
||||
reasoning: None,
|
||||
embedding_status: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn search_titles(out: &serde_json::Value) -> Vec<&str> {
|
||||
out["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| r["title"].as_str().unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 注入通道端到端 + 项目软排序:scope 内执行 search,按会话 boundary 解析归属项目,
|
||||
/// 同项目条目排前(异项目即便复用次数更高,项目档位是第一排序键也压不过)。
|
||||
#[tokio::test]
|
||||
async fn search_scoped_ranks_same_project_first() {
|
||||
let db = test_db().await;
|
||||
ins_project(&db, "p1", "e:/wk-lab/proj-a").await;
|
||||
ins_project(&db, "p2", "e:/wk-lab/proj-b").await;
|
||||
ins_conv_with_boundary(&db, "conv-1", &["e:/wk-lab/proj-a/"]).await;
|
||||
// 两标题对查询「部署踩坑」的 2-gram 命中分相同,全局序下复用 9 的 p2 在前;
|
||||
// scoped 序下 p1 靠项目档位反超。
|
||||
ins_knowledge(&db, "部署踩坑实录", Some("p1"), 0).await;
|
||||
ins_knowledge(&db, "部署踩坑指南", Some("p2"), 9).await;
|
||||
|
||||
let mut registry = AiToolRegistry::new();
|
||||
register(&mut registry, &db);
|
||||
let out = scope_caller_conv("conv-1".to_string(), async {
|
||||
registry
|
||||
.execute("knowledge_search", json!({"query": "部署踩坑"}))
|
||||
.await
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
search_titles(&out),
|
||||
vec!["部署踩坑实录", "部署踩坑指南"],
|
||||
"同项目知识应排前(项目档位为第一排序键)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 无会话上下文(未 scope 直调)降级全局检索:不报错,排序回落标题命中+复用降序,
|
||||
/// 高复用异项目条目回首位,证明走的是全局序而非 scoped 序。
|
||||
#[tokio::test]
|
||||
async fn search_without_scope_falls_back_to_global() {
|
||||
let db = test_db().await;
|
||||
ins_knowledge(&db, "部署踩坑实录", Some("p1"), 0).await;
|
||||
ins_knowledge(&db, "部署踩坑指南", Some("p2"), 9).await;
|
||||
|
||||
let mut registry = AiToolRegistry::new();
|
||||
register(&mut registry, &db);
|
||||
let out = registry
|
||||
.execute("knowledge_search", json!({"query": "部署踩坑"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
search_titles(&out),
|
||||
vec!["部署踩坑指南", "部署踩坑实录"],
|
||||
"无项目上下文应回落全局序(复用降序)且不报错"
|
||||
);
|
||||
}
|
||||
|
||||
/// 注入通道在位性:scope 内 caller_conv 解析出注入的会话 id(search 与 insert 沉淀
|
||||
/// 通道共用同一读取点,后者写入收口批接入);scope 外读不到(None,直调防御)。
|
||||
#[tokio::test]
|
||||
async fn caller_conv_channel_resolves_inside_scope() {
|
||||
assert!(caller_conv().is_none(), "scope 外应读不到会话 id");
|
||||
let seen = scope_caller_conv("conv-chan".to_string(), async { caller_conv() }).await;
|
||||
assert_eq!(seen.as_deref(), Some("conv-chan"), "scope 内应解析出注入的会话 id");
|
||||
}
|
||||
|
||||
/// 写入归属补全(写入收口):scope 内 insert,source_ref 记 conv:{id} 与自动提炼
|
||||
/// 产物同格式,归属项目按会话 boundary 同口径解析(级 a)。
|
||||
#[tokio::test]
|
||||
async fn insert_scoped_records_conv_source_ref() {
|
||||
let db = test_db().await;
|
||||
ins_project(&db, "p1", "e:/wk-lab/proj-a").await;
|
||||
ins_conv_with_boundary(&db, "conv-9", &["e:/wk-lab/proj-a/"]).await;
|
||||
let mut registry = AiToolRegistry::new();
|
||||
register(&mut registry, &db);
|
||||
let out = scope_caller_conv("conv-9".to_string(), async {
|
||||
registry
|
||||
.execute("knowledge_insert", json!({"title": "端口约定", "content": "数据库统一走 3307 代理"}))
|
||||
.await
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out["status"], "candidate");
|
||||
let rec = df_storage::crud::KnowledgeRepo::new(&db)
|
||||
.get_by_id(out["id"].as_str().unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("insert 返回的 id 应可回读");
|
||||
assert_eq!(rec.source_ref.as_deref(), Some("conv:conv-9"), "溯源与自动提炼产物同格式");
|
||||
assert_eq!(rec.source_project.as_deref(), Some("p1"), "归属项目按 boundary 解析");
|
||||
}
|
||||
|
||||
/// 无 scope(直调)降级渠道标记兜底溯源,归属项目留空,不报错。
|
||||
#[tokio::test]
|
||||
async fn insert_without_scope_falls_back_to_channel_ref() {
|
||||
let db = test_db().await;
|
||||
let mut registry = AiToolRegistry::new();
|
||||
register(&mut registry, &db);
|
||||
let out = registry
|
||||
.execute("knowledge_insert", json!({"title": "要点", "content": "内容"}))
|
||||
.await
|
||||
.unwrap();
|
||||
let rec = df_storage::crud::KnowledgeRepo::new(&db)
|
||||
.get_by_id(out["id"].as_str().unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("insert 返回的 id 应可回读");
|
||||
assert_eq!(rec.source_ref.as_deref(), Some("ai-chat"));
|
||||
assert_eq!(rec.source_project, None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user