新增: 知识库图召回激活扩散与治理续批
- 阶段2 图召回: list_neighbors_for_seeds(多种子 1-hop 展开,排除种子与 archived)+knowledge_search 结果附 related 段(权重降序限 5) - 蒸馏合并: knowledge_distill.rs(archived 按 kind 分组 LLM 合订本+merges 边连组) - V54 knowledge_edges 边表与渐进披露批配套迁移/模型字段 - 提炼链 related_ids 协议+检索补 id 字段
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
//! 存量知识蒸馏合并(知识库治理 P0 深化,2026-10-24 用户指令「历史知识内容蒸馏压缩合并」)
|
||||
//!
|
||||
//! 与写入防重(P0,治增量)互补:本模块治存量——已归档长尾(淘汰批 632 条)按 kind 分组,
|
||||
//! LLM 把一组同主题旧知识蒸馏成一条「合订本」(candidate 入库,原条目保持 archived 不删),
|
||||
//! 建 merges 边(合订本→组内每条,origin=distill)保留可溯关系。
|
||||
//!
|
||||
//! 蒸馏语义:合订本是「组内知识的浓缩替代视图」,不是权威合并——原条目仍在库(archived)
|
||||
//! 可随时人工复核;合订本走正常 candidate→复用晋升通道,被人用过才算立住。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
||||
use df_ai::router::{select_model_id, Modality, TaskRequirements};
|
||||
use df_storage::crud::KnowledgeRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::AiProviderRecord;
|
||||
|
||||
use crate::commands::ai::knowledge_inject::{candidate_record_from_item, strip_code_fence};
|
||||
use crate::state::LlmConcurrency;
|
||||
|
||||
use super::secret::build_provider_for;
|
||||
|
||||
/// 单批蒸馏条数上限:每组送 LLM 的条目数。20 条 × 均 218 字 ≈ 4.4K 字符输入,
|
||||
/// 输出单条合订本 2K token 内;更多条分组蒸馏质量劣化(模型记不住开头),宁多批。
|
||||
const DISTILL_GROUP_LIMIT: usize = 20;
|
||||
|
||||
/// 合订本 content 里单条原知识的摘要截断(蒸馏输入侧)
|
||||
const DISTILL_ITEM_SNIPPET: usize = 300;
|
||||
|
||||
const DISTILL_SYSTEM_PROMPT: &str = "你是知识蒸馏引擎。下面是知识库中同一主题下的一组已归档旧知识条目,\
|
||||
它们多为多次对话对同一经验的重复沉淀。请把它们蒸馏成**一条**合并后的知识:\n\
|
||||
- content 融合全组信息,去重、合并同类点、保留每条的独有细节,输出完整可复用的浓缩版\n\
|
||||
- title 概括整组共同主题\n\
|
||||
- tags 取全组标签的并集(最多 5 个)\n\
|
||||
- reasoning 说明蒸馏取舍(丢了什么、为何可丢)\n\n\
|
||||
输出严格的 JSON 对象(不要 markdown 包裹):\n\
|
||||
{\"kind\": \"<沿用组内 kind>\", \"title\": \"...\", \"content\": \"...\", \"tags\": [\"...\"], \"confidence\": \"medium\", \"reasoning\": \"...\"}\n\n\
|
||||
无可蒸馏内容(条目互相无重叠且各自完整)时输出 {\"skip\": true}。只输出 JSON。";
|
||||
|
||||
/// 蒸馏一个已归档分组:LLM 合订本 → candidate 落库 → merges 边连组内全部条目。
|
||||
///
|
||||
/// 返回合订本 id;组内不足 2 条(无可合并)或模型输出 skip 时返回 Ok(None)。
|
||||
/// 节奏控制:重复调用同 kind 会再取同批条目(蒸馏批不做已处理标记,P0 由前端按钮
|
||||
/// 人工控频;merges 边幂等,重复产出合订本可人工在收件箱驳回)。
|
||||
pub async fn distill_archived_group(
|
||||
db: &Arc<Database>,
|
||||
provider_config: &AiProviderRecord,
|
||||
llm_concurrency: &LlmConcurrency,
|
||||
kind: &str,
|
||||
limit: Option<usize>,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
let repo = KnowledgeRepo::new(db);
|
||||
let group = repo
|
||||
.list_by_status_kind("archived", kind, limit.unwrap_or(DISTILL_GROUP_LIMIT).min(50))
|
||||
.await?;
|
||||
if group.len() < 2 {
|
||||
return Ok(None);
|
||||
}
|
||||
// 组装蒸馏输入:编号列表(id 供溯源,正文截断)
|
||||
let mut conv_text = format!("主题分组: {kind}\n\n");
|
||||
for (i, k) in group.iter().enumerate() {
|
||||
let truncated: String;
|
||||
let content = if k.content.chars().count() > DISTILL_ITEM_SNIPPET {
|
||||
truncated = k.content.chars().take(DISTILL_ITEM_SNIPPET).collect();
|
||||
truncated.as_str()
|
||||
} else {
|
||||
k.content.as_str()
|
||||
};
|
||||
conv_text.push_str(&format!("[{}] id={} | {}\n{}\n\n", i + 1, k.id, k.title, content));
|
||||
}
|
||||
|
||||
let messages = vec![
|
||||
ChatMessage::system(DISTILL_SYSTEM_PROMPT),
|
||||
ChatMessage::user(&conv_text),
|
||||
];
|
||||
let req = TaskRequirements {
|
||||
modalities: vec![Modality::Text],
|
||||
needs_tool_use: false,
|
||||
estimated_context: 0,
|
||||
tier: None,
|
||||
};
|
||||
let model = select_model_id(&req, &provider_config.model_configs)
|
||||
.unwrap_or_else(|| provider_config.default_model.clone());
|
||||
let request = CompletionRequest {
|
||||
model,
|
||||
messages,
|
||||
temperature: Some(0.2),
|
||||
max_tokens: Some(2048),
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let provider: Box<dyn LlmProvider> = build_provider_for(provider_config)
|
||||
.map_err(|e| anyhow::anyhow!("provider 密钥不可用: {e}"))?;
|
||||
let _global = llm_concurrency.acquire_global().await;
|
||||
let resp = provider.complete(request).await?;
|
||||
let json_str = strip_code_fence(resp.text.trim());
|
||||
let item: serde_json::Value = serde_json::from_str(json_str)
|
||||
.map_err(|e| anyhow::anyhow!("蒸馏输出 JSON 解析失败: {e} | 原始: {}", resp.text))?;
|
||||
if item.get("skip").and_then(|v| v.as_bool()) == Some(true) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 合订本落库:candidate 信任级(source_ref=distill:{kind}),溯源与自动提炼同风格
|
||||
let batch_ref = format!("distill:{}", kind);
|
||||
let record = candidate_record_from_item(&item, None, Some(batch_ref.clone()))
|
||||
.ok_or_else(|| anyhow::anyhow!("蒸馏产出字段残缺(kind/title/content 缺失)"))?;
|
||||
repo.insert(record.clone()).await?;
|
||||
let timeline = crate::commands::knowledge_timeline::KnowledgeTimeline::new(db);
|
||||
timeline
|
||||
.record_extracted(
|
||||
&record.id,
|
||||
"distill-batch",
|
||||
&format!("蒸馏合订本({kind})"),
|
||||
record.reasoning.as_deref().unwrap_or(""),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
// merges 边:合订本 → 组内每条(insert_edge 双向去重幂等,单边失败非阻断)
|
||||
let mut linked = 0usize;
|
||||
for k in &group {
|
||||
if repo.insert_edge(&record.id, &k.id, "merges", "distill").await.is_ok() {
|
||||
linked += 1;
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
"[knowledge-distill] {kind} 组蒸馏完成: 合订本 {} ← {linked} 条边(group={})",
|
||||
record.id,
|
||||
group.len()
|
||||
);
|
||||
Ok(Some(record.id))
|
||||
}
|
||||
@@ -50,6 +50,12 @@ const KNOWLEDGE_EXTRACT_WINDOW: usize = 40;
|
||||
///
|
||||
/// 取代原「每会话仅提炼一次」的 bool 硬去重——长会话后半程的新经验此前永不提炼。
|
||||
const KNOWLEDGE_EXTRACT_MIN_NEW_USER_MSGS: usize = 10;
|
||||
/// 提炼查重候选数:随 prompt 附给 LLM 的已有知识条数(P0 写入防重,2026-10-23)
|
||||
///
|
||||
/// 5 条 × 500 字截断 ≈ 2.5K 字符,足以覆盖本轮对话主题的已有知识面;更多会稀释
|
||||
/// 提炼输入预算与 LLM 对单条的注意力。与注入 limit=3 同量级但不等值(注入防膨胀,
|
||||
/// 查重要盖住高频重复区)。
|
||||
const KNOWLEDGE_DEDUP_CANDIDATES: usize = 5;
|
||||
|
||||
/// 自动提炼频次水位(conv_id → 上次提炼进度),见 [`EXTRACT_WATERMARKS`]。
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -235,6 +241,11 @@ async fn resolve_embed_provider(
|
||||
/// 生成文本嵌入(向量检索用)
|
||||
///
|
||||
/// 用配置指定的 embedding provider(必须 openai_compat 类型),失败返回 None(降级 LIKE)。
|
||||
///
|
||||
/// ⚠️ P0 定位变更(2026-10-23 知识库治理审查拍板):向量路径的「检索用途」明确弃用——
|
||||
/// 生产实证 0/1688 条有向量(provider 从未配成,本函数返回 None 即天然跳过,降级 LIKE
|
||||
/// 已是唯一实际路径)。检索独走分词 LIKE;本链路保留不删,阶段 3(知识中枢设计 2026-10-23)
|
||||
/// 改职复活为「自动建边引擎」(相似度>阈值=similar 边候选),届时不再做检索排序。
|
||||
async fn generate_embedding(
|
||||
state: &AppState,
|
||||
text: &str,
|
||||
@@ -894,13 +905,20 @@ const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话
|
||||
输出严格的 JSON 数组(不要 markdown 代码块包裹),每个元素 schema:\n\
|
||||
{\"kind\": \"review_rule|prompt_template|pitfall|architecture_pattern|diagnosis|deployment_note|workflow_optimization\", \
|
||||
\"title\": \"简短标题\", \"content\": \"完整可复用内容\", \
|
||||
\"tags\": [\"标签\"], \"confidence\": \"high|medium|low\", \"reasoning\": \"为何值得沉淀\"}\n\n\
|
||||
\"tags\": [\"标签\"], \"confidence\": \"high|medium|low\", \"reasoning\": \"为何值得沉淀\", \
|
||||
\"action\": \"new|update\", \"target_id\": \"已有条目id(仅 update 时)\", \
|
||||
\"related_ids\": [\"强相关的已有条目id\"](可选,最多3个,仅 action=new 时)}\n\n\
|
||||
规则:\n\
|
||||
1. 如果适用范围有限制(如仅适用特定语言/框架/场景),必须在 content 或 tags 中明确标注\n\
|
||||
2. confidence: high=对话中可直接观察的明确模式, medium=合理推断, low=推测性弱信号\n\
|
||||
3. 无可提炼内容时返回空数组 []\n\
|
||||
4. 输出纯 JSON,无任何额外文字\n\
|
||||
5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)";
|
||||
5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)\n\
|
||||
6. 查重融合:若本次经验与「已有知识库条目」中某条重复或强相关,必须输出 action=\"update\" \
|
||||
且 target_id=该条 id,content 给出融合两方后的完整新版(不要重复旧文已有信息),\
|
||||
禁止同义新增造成知识重复;确属新经验才 action=\"new\"(可省略 action 字段)\n\
|
||||
7. 关联标注:action=\"new\" 时,若与已有条目存在强相关(同一主题的互补/前提/对照),\
|
||||
在 related_ids 数组里列出最多 3 个相关条目 id,供知识关联网络建边;无强相关则省略该字段";
|
||||
|
||||
/// 终态提炼提示词 — 在常规提示词上加终态边界(设计 §3.1):产出限跨会话可复用经验,
|
||||
/// 未完成事项/待办是会话任务态不是知识,归 L4 目标快照与停靠说明承接,不进知识表。
|
||||
@@ -1356,7 +1374,7 @@ fn conv_title_or_placeholder(title: Option<String>) -> String {
|
||||
/// LLM 产出单条 → candidate 知识记录(常规/终态提炼共用的落库统一口径)。
|
||||
/// kind 缺失或标题/正文为空返回 None(调用方跳过该条);status 恒 candidate(LLM 写入
|
||||
/// 不自动发布,与工具沉淀同信任级);归属与溯源 source_ref 由调用方按统一口径给定。
|
||||
fn candidate_record_from_item(
|
||||
pub(crate) fn candidate_record_from_item(
|
||||
item: &serde_json::Value,
|
||||
source_project: Option<&str>,
|
||||
source_ref: Option<String>,
|
||||
@@ -1417,8 +1435,19 @@ async fn run_extraction_core(
|
||||
Some(mid) => format!("conv_msg:{}", mid),
|
||||
None => format!("conv:{}", conv_id),
|
||||
});
|
||||
// P0 写入防重(2026-10-23):提炼前按窗口关键词查已有知识 top5 随 prompt 附给 LLM,
|
||||
// 重复经验走 update 融合而非同义新增——治理审查实测零查重是 10 月 695 条膨胀的主因。
|
||||
// 全局检索(project=None):知识重复不分项目。检索失败非阻断(降级为无候选,行为回旧)。
|
||||
let existing = match extract_existing_candidates(db, conv_text).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("[knowledge] 查重候选检索失败,降级无查重提炼(非阻断): {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let system_full = append_existing_block(system_prompt, &existing);
|
||||
let extract_messages = vec![
|
||||
ChatMessage::system(system_prompt),
|
||||
ChatMessage::system(&system_full),
|
||||
ChatMessage::user(&format!("请从以下对话中提炼可复用知识:\n\n{}", conv_text)),
|
||||
];
|
||||
// F-01 阶段5: 知识提炼路由 — TaskRequirements(Standard,无工具)。
|
||||
@@ -1498,7 +1527,45 @@ async fn run_extraction_core(
|
||||
tracing::info!(conv_id, project_id = %pid, "[KNOWLEDGE-EXTRACT] 本轮产出知识归属项目");
|
||||
}
|
||||
let mut inserted = 0;
|
||||
let mut updated = 0;
|
||||
// update 分派的合法性锚:target_id 必须命中本轮附给 LLM 的候选集(防幻觉 id 误改他条)
|
||||
let existing_ids: std::collections::HashSet<&str> =
|
||||
existing.iter().map(|r| r.id.as_str()).collect();
|
||||
for item in &items {
|
||||
// P0 查重融合路径:action=update 且 target_id 命中候选集 → 覆盖已有条目而非新建。
|
||||
// content 缺失/空白与 target 未命中都降级走新建(内容不丢,与旧行为一致)。
|
||||
let update_target = (item.get("action").and_then(|v| v.as_str()) == Some("update"))
|
||||
.then(|| item.get("target_id").and_then(|v| v.as_str()))
|
||||
.flatten()
|
||||
.filter(|tid| existing_ids.contains(*tid));
|
||||
if let Some(tid) = update_target {
|
||||
let Some(new_content) = item
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let reasoning_line = format!(
|
||||
"update@{}: {}",
|
||||
chrono::Utc::now().format("%Y-%m-%d"),
|
||||
item.get("reasoning").and_then(|v| v.as_str()).unwrap_or("融合重复经验")
|
||||
);
|
||||
match knowledge_repo
|
||||
.update_content_merged(tid, new_content, &reasoning_line)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
updated += 1;
|
||||
tracing::info!("AI 提炼知识融合更新: {tid}(重复经验并入已有条目)");
|
||||
timeline.record_updated(tid, &["content"], source_ref.as_deref().unwrap_or("extract")).await;
|
||||
}
|
||||
Ok(false) => tracing::warn!("知识融合更新未命中(id 已消失?),降级新建: {tid}"),
|
||||
Err(e) => tracing::warn!("知识融合更新失败(非阻断,降级新建): {e}"),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(record) = candidate_record_from_item(item, source_project.as_deref(), source_ref.clone()) else {
|
||||
continue;
|
||||
};
|
||||
@@ -1520,18 +1587,78 @@ async fn run_extraction_core(
|
||||
last_assistant_msg_id,
|
||||
)
|
||||
.await;
|
||||
// 阶段1 连边:related_ids ∩ 候选集 → similar 边(方向=新条目→相关已有条目)。
|
||||
// 幂等(insert_edge 双向去重),单边失败非阻断只 warn。
|
||||
let related = item
|
||||
.get("related_ids")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
for rid in related.into_iter().take(3) {
|
||||
if !existing_ids.contains(rid) || rid == record.id {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = knowledge_repo.insert_edge(&record.id, rid, "similar", "llm-tag").await {
|
||||
tracing::warn!("知识关联边建立失败(非阻断): {}→{}: {e}", record.id, rid);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("知识候选插入失败(非阻断): {}", e),
|
||||
}
|
||||
}
|
||||
if inserted > 0 {
|
||||
tracing::info!("知识提炼完成: 对话 {} 产出 {} 条 candidate", conv_id, inserted);
|
||||
// 水位语义「有产出才推进」:update 与 new 同为产出(0 new + N update 不再误判无产出)
|
||||
if inserted + updated > 0 {
|
||||
tracing::info!(
|
||||
"知识提炼完成: 对话 {conv_id} 新建 {inserted} 条 / 融合更新 {updated} 条"
|
||||
);
|
||||
}
|
||||
Ok(inserted)
|
||||
Ok(inserted + updated)
|
||||
}
|
||||
|
||||
/// 提炼查重候选检索:按窗口关键词查已有知识 top5(P0 写入防重,2026-10-23)
|
||||
///
|
||||
/// 走与注入同源的 [`extract_query_keywords`] 分词 + [`KnowledgeRepo::search_keywords`]
|
||||
/// (include_candidates=true 与检索面同口径)。降级链:分词空词/0 命中 → 空表(LLM 收不到
|
||||
/// 已有块,全部走 new,行为回旧)。
|
||||
async fn extract_existing_candidates(
|
||||
db: &Arc<Database>,
|
||||
conv_text: &str,
|
||||
) -> anyhow::Result<Vec<KnowledgeRecord>> {
|
||||
let keywords = extract_query_keywords(conv_text);
|
||||
if keywords.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let repo = KnowledgeRepo::new(db);
|
||||
Ok(repo.search_keywords(&keywords, true, KNOWLEDGE_DEDUP_CANDIDATES).await?)
|
||||
}
|
||||
|
||||
/// 把已有知识候选块追加到提炼 system prompt(action=update 协议的上下文载体)
|
||||
///
|
||||
/// 每条带 id/title/content 截断(500 字,LLM 判断重复足够,不撑提炼输入预算);
|
||||
/// 候选为空返回原 prompt(零开销)。
|
||||
fn append_existing_block(system_prompt: &str, existing: &[KnowledgeRecord]) -> String {
|
||||
if existing.is_empty() {
|
||||
return system_prompt.to_owned();
|
||||
}
|
||||
let mut block = String::from("\n\n已有知识库条目(判断查重用):\n");
|
||||
for r in existing {
|
||||
let truncated: String;
|
||||
let content = if r.content.chars().count() > 500 {
|
||||
truncated = r.content.chars().take(500).collect();
|
||||
truncated.as_str()
|
||||
} else {
|
||||
r.content.as_str()
|
||||
};
|
||||
block.push_str(&format!(
|
||||
"- id={} | {} | {}\n",
|
||||
r.id, r.title, content
|
||||
));
|
||||
}
|
||||
format!("{system_prompt}{block}")
|
||||
}
|
||||
|
||||
/// 剥离 LLM 输出可能的 ```json ... ``` 代码块包裹
|
||||
fn strip_code_fence(s: &str) -> &str {
|
||||
pub(crate) fn strip_code_fence(s: &str) -> &str {
|
||||
let s = s.trim();
|
||||
if let Some(rest) = s.strip_prefix("```json") {
|
||||
return rest.trim().trim_end_matches("```").trim();
|
||||
|
||||
@@ -92,6 +92,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
.iter()
|
||||
.map(|k| {
|
||||
serde_json::json!({
|
||||
"id": k.id,
|
||||
"title": k.title,
|
||||
"kind": k.kind,
|
||||
"content": truncate_chars_ellipsis(&k.content, SEARCH_CONTENT_SNIPPET_LEN),
|
||||
@@ -100,22 +101,46 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::json!({ "results": results }))
|
||||
// 图召回·激活扩散(阶段2,2026-10-26):命中集沿边扩一跳,把「连着的知识」带出
|
||||
// 检索面——排除已命中与 archived,按边权重降序限 5 条;related 不占 limit 配额,
|
||||
// 空数组=无关联(语义清晰,LLM 可忽略)。
|
||||
let seed_ids: Vec<String> = hits.iter().map(|k| k.id.clone()).collect();
|
||||
let related: Vec<serde_json::Value> = repo
|
||||
.list_neighbors_for_seeds(&seed_ids, 5)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|n| {
|
||||
serde_json::json!({
|
||||
"id": n.knowledge.id,
|
||||
"title": n.knowledge.title,
|
||||
"kind": n.knowledge.kind,
|
||||
"edge_kind": n.edge.kind,
|
||||
"direction": n.direction,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::json!({ "results": results, "related": related }))
|
||||
}
|
||||
);
|
||||
|
||||
// 沉淀工具:用户说「记住/记一下/以后注意」时把要点落为候选知识
|
||||
// 沉淀工具:用户说「记住/记一下/以后注意」时把要点落为候选知识。
|
||||
// P0 写入防重(2026-10-23):工具无「再问一次 LLM」的查重机会,防重靠描述引导
|
||||
// (先 search 后写) + update_target_id 融合口径——agent 检索发现重复时传已有条目
|
||||
// id 走融合更新,禁同义新增。
|
||||
declare_tool!(
|
||||
registry,
|
||||
db: Arc<Database>,
|
||||
"knowledge_insert",
|
||||
"把一条经验/约定/踩坑要点沉淀进本地知识库(写入为候选知识,经复用或人工审核后转正,不会立即进入检索)。使用时机:用户说「记住/记一下/以后注意/这是我踩过的坑」时调用,先提炼要点再写入。参数:title=一句话概括(必填),content=要点详情(必填),kind=知识类型(可选,常用:pitfall 踩坑/architecture_pattern 架构模式/diagnosis 诊断/review_rule 评审规则/prompt_template 提示词模板/deployment_note 部署要点/workflow_optimization 流程优化,默认 pitfall),tags=标签数组(可选)。回答前查已有知识请用 knowledge_search",
|
||||
"把一条经验/约定/踩坑要点沉淀进本地知识库(写入为候选知识,经复用或人工审核后转正,不会立即进入检索)。使用时机:用户说「记住/记一下/以后注意/这是我踩过的坑」时调用,先提炼要点再写入。写入前必须先 knowledge_search 查重:与已有条目重复或强相关时不要新增,改传 update_target_id=该条 ID 做融合更新(此时 content 给融合后的完整内容,title 忽略),禁止同义新增造成知识重复。参数:title=一句话概括(新增时必填),content=要点详情(必填),kind=知识类型(可选,常用:pitfall 踩坑/architecture_pattern 架构模式/diagnosis 诊断/review_rule 评审规则/prompt_template 提示词模板/deployment_note 部署要点/workflow_optimization 流程优化,默认 pitfall),tags=标签数组(可选),update_target_id=已有知识ID(可选,融合更新而非新增)",
|
||||
RiskLevel::Low,
|
||||
schema: object_schema(vec![
|
||||
("title", "string", true),
|
||||
("content", "string", true),
|
||||
("kind", "string", false),
|
||||
("tags", "array", false),
|
||||
("update_target_id", "string", false),
|
||||
("related_ids", "array", false),
|
||||
]),
|
||||
args => {
|
||||
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
|
||||
@@ -123,6 +148,28 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
if title.trim().is_empty() || content.trim().is_empty() {
|
||||
anyhow::bail!("title 和 content 不能为空");
|
||||
}
|
||||
let repo = df_storage::crud::KnowledgeRepo::new(&db);
|
||||
// P0 融合更新路径:update_target_id 命中已有条目 → 覆盖 content 并留轨迹;
|
||||
// 未命中(false=条目已消失)向 agent 明确报错,由其降级重新调用(不带该参数)
|
||||
if let Some(tid) = args["update_target_id"].as_str().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
let reasoning_line = format!(
|
||||
"update@{}: agent 对话沉淀融合(来源 {})",
|
||||
chrono::Utc::now().format("%Y-%m-%d"),
|
||||
caller_conv().map(|c| format!("conv:{c}")).unwrap_or_else(|| "ai-chat".into())
|
||||
);
|
||||
let merged = repo
|
||||
.update_content_merged(tid, content.trim(), &reasoning_line)
|
||||
.await?;
|
||||
if !merged {
|
||||
anyhow::bail!("update_target_id={tid} 的知识不存在(可能已删除),请不带该参数重新调用以新增");
|
||||
}
|
||||
return Ok(serde_json::json!({
|
||||
"id": tid,
|
||||
"content": content,
|
||||
"action": "update",
|
||||
"note": "已融合更新已有知识,未新增条目"
|
||||
}));
|
||||
}
|
||||
// 非空即透传不做硬白名单(见模块注释:snippet 先例,过严校验制造假失败)
|
||||
let kind = match args["kind"].as_str() {
|
||||
Some(k) if !k.trim().is_empty() => k.trim().to_string(),
|
||||
@@ -169,11 +216,29 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
let kind_out = record.kind.clone();
|
||||
let repo = df_storage::crud::KnowledgeRepo::new(&db);
|
||||
repo.insert(record).await?;
|
||||
// 阶段1 连边:related_ids(知识检索结果里的已有条目)逐个校验存在后建 similar 边,
|
||||
// 幂等且非阻断(单边失败只 warn,不回滚主写入)
|
||||
let mut linked = 0usize;
|
||||
if let Some(arr) = args["related_ids"].as_array() {
|
||||
for rid in arr.iter().filter_map(|v| v.as_str()).take(3) {
|
||||
if rid == id {
|
||||
continue;
|
||||
}
|
||||
if repo.get_by_id(rid).await.unwrap_or(None).is_none() {
|
||||
tracing::warn!("related_id 不存在,跳过建边: {rid}");
|
||||
continue;
|
||||
}
|
||||
if repo.insert_edge(&id, rid, "similar", "llm-tag").await.is_ok() {
|
||||
linked += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"id": id,
|
||||
"title": title,
|
||||
"kind": kind_out,
|
||||
"status": "candidate",
|
||||
"linked": linked,
|
||||
"note": "已记录为候选知识,复用或审核后转正"
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde::Deserialize;
|
||||
use tauri::State;
|
||||
|
||||
use df_types::types::new_id;
|
||||
use df_storage::models::{KnowledgeEventRecord, KnowledgeRecord};
|
||||
use df_storage::models::{KnowledgeEventRecord, KnowledgeRecord, RelatedKnowledge};
|
||||
|
||||
use crate::state::{AppState, KnowledgeConfig};
|
||||
|
||||
@@ -303,6 +303,80 @@ pub async fn knowledge_retry_embedding(
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 淘汰批:零复用 + low 置信 + 超龄条目批量归档(P0 淘汰机制,2026-10-23)
|
||||
///
|
||||
/// 背景(知识库治理审查 2026-10-23):71% 条目零复用长尾稀释检索排序,检索面是
|
||||
/// status 白名单,archived 天然出池。逐条留痕(status_changed,source_ref=archive_stale)
|
||||
/// 可审计可回滚。默认阈值 90 天/批 500——从生产画像看 low 置信存量极少(实测 1 条),
|
||||
/// 阈值偏保守,宁可分批多跑不误伤。
|
||||
///
|
||||
/// 归档不可逆性:archived 条目退出检索面,可通过 knowledge_update_status 手动转回
|
||||
/// published(状态机合法迁移),留痕链完整。
|
||||
#[tauri::command]
|
||||
pub async fn knowledge_archive_stale(
|
||||
state: State<'_, AppState>,
|
||||
older_than_days: Option<i64>,
|
||||
limit: Option<i64>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let days = older_than_days.unwrap_or(90).clamp(1, 3650);
|
||||
let limit = limit.unwrap_or(500).clamp(1, 5000);
|
||||
let ids = state
|
||||
.knowledge
|
||||
.archive_stale(days, limit)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
// 逐条留痕 fire-and-forget(与晋升留痕同风格,吞错只 warn)。
|
||||
// from 记 "stale" 而非逐条旧值(批量 UPDATE 已合并原状态,淘汰批标记便于审计回滚)
|
||||
let timeline = KnowledgeTimeline::new(&state.db);
|
||||
for id in &ids {
|
||||
timeline.record_status_change(id, "stale", "archived").await;
|
||||
}
|
||||
if !ids.is_empty() {
|
||||
tracing::info!("[knowledge] 淘汰批: {} 条零复用低置信超龄条目归档(>{days}天)", ids.len());
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// 关联知识列表(知识中枢阶段1:详情页「关联知识」区块数据源)
|
||||
///
|
||||
/// 双向读边表 + 对端条目展开,archived 对端不展示(与检索面口径一致)。
|
||||
#[tauri::command]
|
||||
pub async fn knowledge_list_related(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
limit: Option<i64>,
|
||||
) -> Result<Vec<RelatedKnowledge>, String> {
|
||||
let limit = limit.unwrap_or(10).clamp(1, 50) as usize;
|
||||
state
|
||||
.knowledge
|
||||
.list_related(&id, limit)
|
||||
.await
|
||||
.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 存量蒸馏合并(治理 P0 深化,2026-10-24):对指定 kind 的已归档分组跑一次 LLM 蒸馏,
|
||||
/// 产出单条合订本(candidate)并建 merges 边连组内条目。返回合订本 id(组内不足 2 条
|
||||
/// 或模型判 skip 返回 None)。
|
||||
#[tauri::command]
|
||||
pub async fn knowledge_distill_group(
|
||||
state: State<'_, AppState>,
|
||||
kind: String,
|
||||
limit: Option<i64>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let provider_config = crate::commands::ai::prompt::get_active_provider(&state)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
crate::commands::ai::knowledge_distill::distill_archived_group(
|
||||
&state.db,
|
||||
&provider_config,
|
||||
&state.llm_concurrency,
|
||||
&kind,
|
||||
limit.map(|n| n as usize),
|
||||
)
|
||||
.await
|
||||
.map_err(err_str)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 配置 + 手动提炼
|
||||
// ============================================================
|
||||
@@ -367,14 +441,17 @@ pub struct UpdateKnowledgeInput {
|
||||
pub reasoning: Option<String>,
|
||||
}
|
||||
|
||||
/// 知识详情聚合负载(基本信息 + 全部生命线事件)
|
||||
/// 知识详情聚合负载(基本信息 + 全部生命线事件 + 关联知识)
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct KnowledgeDetailPayload {
|
||||
pub knowledge: KnowledgeRecord,
|
||||
pub events: Vec<KnowledgeEventRecord>,
|
||||
/// 关联知识(阶段1 连边,双向读+对端展开);老前端不消费此字段无影响
|
||||
#[serde(default)]
|
||||
pub related: Vec<RelatedKnowledge>,
|
||||
}
|
||||
|
||||
/// 知识详情(基本信息 + 生命线事件) — 详情页一次拉全
|
||||
/// 知识详情(基本信息 + 生命线事件 + 关联知识) — 详情页一次拉全
|
||||
#[tauri::command]
|
||||
pub async fn knowledge_get_detail(
|
||||
state: State<'_, AppState>,
|
||||
@@ -391,7 +468,9 @@ pub async fn knowledge_get_detail(
|
||||
.list_by_knowledge(&id)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
Ok(KnowledgeDetailPayload { knowledge, events })
|
||||
// 关联边读取失败非阻断(边表为空/极老库未到 V54 时降级空列表)
|
||||
let related = state.knowledge.list_related(&id, 10).await.unwrap_or_default();
|
||||
Ok(KnowledgeDetailPayload { knowledge, events, related })
|
||||
}
|
||||
|
||||
/// 编辑知识(部分更新 title/content/tags/confidence/reasoning)
|
||||
|
||||
Reference in New Issue
Block a user