//! 知识库相关命令 — 共享记忆层(沉淀 / 检索注入 / 审核收件箱 / 配置) //! //! 11 个 command 对齐 MCP 语义(search/list/get/create/update_status/record_reuse 可对外暴露, //! archive/get_config/save_config/extract_now/list_candidates 为内部便利方法)。 use serde::Deserialize; use tauri::State; use df_types::types::new_id; use df_storage::models::{KnowledgeEventRecord, KnowledgeRecord, RelatedKnowledge}; use crate::state::{AppState, KnowledgeConfig}; use super::knowledge_timeline::KnowledgeTimeline; use super::{err_str, now_millis}; use serde::Serialize; /// 创建知识入参 #[derive(Debug, Deserialize)] pub struct CreateKnowledgeInput { /// 7 种 KnowledgeKind snake_case 之一 pub kind: String, pub title: String, pub content: String, /// 标签 JSON 数组字符串 #[serde(default)] pub tags: Option, #[serde(default)] pub source_project: Option, #[serde(default)] pub source_ref: Option, /// high | medium | low #[serde(default)] pub confidence: Option, } /// 检索入参 #[derive(Debug, Deserialize)] pub struct KnowledgeSearchInput { pub query: String, #[serde(default)] pub kind: Option, #[serde(default)] pub limit: Option, } /// 状态转换合法矩阵校验 /// /// candidate → pending_review | published | rejected | archived /// pending_review → published | rejected | archived /// published → archived /// rejected → candidate(重新送审;rejected 独立分池语义:拒绝≠归档, /// 被否决的知识可修改后重审,也可留在拒绝池沉淀——与 archived 分开可见) /// archived → published(归档箱恢复;2026-10-05 归档功能尾巴——原 archived 为汇点, /// 归档即不可逆,与项目/任务/灵感/会话归档「可逆语义」不一致) /// (其他组合非法) fn validate_transition(from: &str, to: &str) -> Result<(), String> { let legal = match from { "candidate" => matches!(to, "pending_review" | "published" | "rejected" | "archived"), "pending_review" => matches!(to, "published" | "rejected" | "archived"), "published" => matches!(to, "archived"), "rejected" => matches!(to, "candidate"), "archived" => matches!(to, "published"), _ => false, }; if legal { Ok(()) } else { Err(format!("非法状态转换: {from} → {to}")) } } // ============================================================ // CRUD // ============================================================ /// 列出知识 — 可按 status 筛选,status=None 时默认仅返回 published /// /// 默认范围说明(F-260616-02 决策 a): /// - status=None → library tab 的数据源,语义为「已发布知识库」,仅 published。 /// 不再混杂 pending_review(pending_review 归 inbox 收件箱,见 knowledge_list_candidates)。 /// - 显式传 status 时按该 status 过滤(含 archived)。 #[tauri::command] pub async fn knowledge_list( state: State<'_, AppState>, status: Option, ) -> Result, String> { match status { // 显式传 status 时按该 status 过滤(含 archived) Some(s) => state.knowledge.list_by_status(&s).await.map_err(err_str), // 默认:library 仅 published(F-260616-02 决策 a,职责清晰:library=published,inbox=待处理) None => state.knowledge.list_by_status("published").await.map_err(err_str), } } /// 单条查询 #[tauri::command] pub async fn knowledge_get( state: State<'_, AppState>, id: String, ) -> Result { state .knowledge .get_by_id(&id) .await .map_err(err_str)? .ok_or_else(|| format!("知识不存在: {id}")) } /// 检索知识(top-N,默认 3) — MCP search tool #[tauri::command] pub async fn knowledge_search( state: State<'_, AppState>, input: KnowledgeSearchInput, ) -> Result, String> { let limit = input.limit.unwrap_or(3).min(20); state .knowledge .search(&input.query, input.kind.as_deref(), limit) .await .map_err(err_str) } /// 创建知识(始终 candidate 状态) — MCP create tool #[tauri::command] pub async fn knowledge_create( state: State<'_, AppState>, input: CreateKnowledgeInput, ) -> Result { let now = now_millis(); let record = KnowledgeRecord { id: new_id(), kind: input.kind, title: input.title, content: input.content, tags: input.tags, status: "candidate".to_string(), confidence: input.confidence, reuse_count: 0, verified: false, source_project: input.source_project, source_ref: input.source_ref, reasoning: None, embedding_status: None, created_at: now.clone(), updated_at: now, }; state .knowledge .insert(record.clone()) .await .map_err(err_str)?; // 生命线:手动录入产生(fire-and-forget) KnowledgeTimeline::new(&state.db) .record_created(&record.id, "manual") .await; Ok(record) } // ============================================================ // 状态机 + 计数 // ============================================================ /// 状态转换(含合法矩阵校验) — MCP update_status tool #[tauri::command] pub async fn knowledge_update_status( state: State<'_, AppState>, id: String, status: String, ) -> Result { let record = state .knowledge .get_by_id(&id) .await .map_err(err_str)? .ok_or_else(|| format!("知识不存在: {id}"))?; let old_status = record.status.clone(); validate_transition(&record.status, &status)?; // published 时一次性标 verified=true(发布审核标) let now = now_millis(); let updated = KnowledgeRecord { status: status.clone(), verified: if status == "published" { true } else { record.verified }, updated_at: now, ..record }; let result = state .knowledge .update_full(&updated) .await .map_err(err_str)?; // 生命线:状态变更(fire-and-forget;to=published=审核通过,to=archived=归档) KnowledgeTimeline::new(&state.db) .record_status_change(&id, &old_status, &status) .await; // 发布时后台生成嵌入(只有 published 参与检索,candidate 不浪费 embed 调用; // vector_enabled 关闭或 embed 失败时该条走 LIKE 降级,非阻断) if status == "published" { crate::commands::ai::spawn_embedding_for_knowledge(&state, &updated).await; } Ok(result) } /// 复用计数 +1(检索命中时调用) — MCP record_reuse tool #[tauri::command] pub async fn knowledge_record_reuse( state: State<'_, AppState>, id: String, ) -> Result { state .knowledge .increment_reuse_count(&id) .await .map_err(err_str) } /// 收件箱 — 列出待处理条目(candidate + pending_review),按 confidence 语义排序 /// /// 语义(F-260616-02 决策 a):inbox = 「待处理」收件箱,聚合 candidate(待评估) /// 与 pending_review(待发布审核)两种待处理状态。library 仅 published。 /// /// 实现:list_by_status 单状态查询,这里合并 candidate 与 pending_review 两路结果。 /// 两路各自已按 `confidence DESC, created_at DESC` 排序,有序合并保持同一规则。 #[tauri::command] pub async fn knowledge_list_candidates( state: State<'_, AppState>, ) -> Result, String> { let (candidates, pending) = tokio::try_join!( state.knowledge.list_by_status("candidate"), state.knowledge.list_by_status("pending_review"), ) .map_err(err_str)?; Ok(merge_by_confidence(candidates, pending)) } /// 有序合并两列(各自已按 confidence DESC, created_at DESC 排序),结果保持同序。 /// /// confidence 排序权重:high=3, medium=2, low=1, 其他=0;同权重按 created_at DESC /// (字符串毫秒时间戳字典序 = 时间序)。等价 SQL `ORDER BY CASE confidence ... DESC, created_at DESC`。 fn merge_by_confidence( mut a: Vec, mut b: Vec, ) -> Vec { use std::cmp::Ordering; fn rank(c: &str) -> i8 { match c { "high" => 3, "medium" => 2, "low" => 1, _ => 0, } } let cmp = |x: &KnowledgeRecord, y: &KnowledgeRecord| -> Ordering { let rx = rank(x.confidence.as_deref().unwrap_or("")); let ry = rank(y.confidence.as_deref().unwrap_or("")); ry.cmp(&rx) // confidence DESC .then_with(|| y.created_at.cmp(&x.created_at)) // created_at DESC }; a.sort_by(cmp); b.sort_by(cmp); let mut out = Vec::with_capacity(a.len() + b.len()); let (mut i, mut j) = (0, 0); while i < a.len() && j < b.len() { if cmp(&a[i], &b[j]) != Ordering::Greater { out.push(a[i].clone()); i += 1; } else { out.push(b[j].clone()); j += 1; } } out.extend_from_slice(&a[i..]); out.extend_from_slice(&b[j..]); out } /// 归档(软删除) — UPDATE status='archived' #[tauri::command] pub async fn knowledge_archive( state: State<'_, AppState>, id: String, ) -> Result { knowledge_update_status(state, id, "archived".to_string()).await } /// 重新生成向量嵌入(补偿重试)— P1 修复(嵌入失败无标记)的补偿入口 /// /// 触发条件:embedding_status='failed' 的已发布知识(provider 临时不可用导致永久无向量索引)。 /// 行为:fire-and-forget,后台逐条重跑嵌入生成(成功置 done,失败仍 failed 可再次触发)。 /// 返回值:本次触发重试的条数(0 表示无 failed 条目或 vector_enabled 关闭)。 #[tauri::command] pub async fn knowledge_retry_embedding( state: State<'_, AppState>, ) -> Result { let count = crate::commands::ai::retry_failed_embeddings(&state) .await .map_err(err_str)?; 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, limit: Option, ) -> Result, 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, ) -> Result, 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, ) -> Result, 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) } // ============================================================ // 配置 + 手动提炼 // ============================================================ /// 读取知识库行为配置(提取 + 注入) #[tauri::command] pub async fn knowledge_get_config( state: State<'_, AppState>, ) -> Result { let cfg = state.knowledge_config.lock().await; Ok(cfg.clone()) } /// 保存知识库行为配置 #[tauri::command] pub async fn knowledge_save_config( state: State<'_, AppState>, config: KnowledgeConfig, ) -> Result { // P0(设置走查-2026-06-21):落 Settings KV 持久化,reload_knowledge_config 启动恢复 // (原纯内存 Arc 启动 default 覆盖致 8 项配置重启全丢)。 let json = serde_json::to_string(&config).map_err(|e| e.to_string())?; { let mut cfg = state.knowledge_config.lock().await; *cfg = config; } state .settings .set(crate::state::KNOWLEDGE_CONFIG_KEY, &json) .await .map_err(|e| e.to_string())?; Ok(true) } /// 手动触发提炼(ManualOnly 模式 / 用户主动点按钮) /// /// 实际提炼逻辑在 commands::ai 模块(需访问 active provider + conversation messages)。 /// 此 command 仅作前端入口,委托给 ai 模块的提炼函数。 #[tauri::command] pub async fn knowledge_extract_now( state: State<'_, AppState>, ) -> Result { crate::commands::ai::trigger_extraction_now(&state).await } // ============================================================ // 生命线:详情 / 编辑 / 事件查询 // ============================================================ /// 编辑知识入参(部分更新,仅传需改字段) #[derive(Debug, Deserialize)] pub struct UpdateKnowledgeInput { #[serde(default)] pub title: Option, #[serde(default)] pub content: Option, #[serde(default)] pub tags: Option, #[serde(default)] pub confidence: Option, #[serde(default)] pub reasoning: Option, } /// 知识详情聚合负载(基本信息 + 全部生命线事件 + 关联知识) #[derive(Debug, Serialize)] pub struct KnowledgeDetailPayload { pub knowledge: KnowledgeRecord, pub events: Vec, /// 关联知识(阶段1 连边,双向读+对端展开);老前端不消费此字段无影响 #[serde(default)] pub related: Vec, } /// 知识详情(基本信息 + 生命线事件 + 关联知识) — 详情页一次拉全 #[tauri::command] pub async fn knowledge_get_detail( state: State<'_, AppState>, id: String, ) -> Result { let knowledge = state .knowledge .get_by_id(&id) .await .map_err(err_str)? .ok_or_else(|| format!("知识不存在: {id}"))?; let events = state .knowledge_events .list_by_knowledge(&id) .await .map_err(err_str)?; // 关联边读取失败非阻断(边表为空/极老库未到 V54 时降级空列表) let related = state.knowledge.list_related(&id, 10).await.unwrap_or_default(); Ok(KnowledgeDetailPayload { knowledge, events, related }) } /// 编辑知识(部分更新 title/content/tags/confidence/reasoning) #[tauri::command] pub async fn knowledge_update( state: State<'_, AppState>, id: String, input: UpdateKnowledgeInput, ) -> Result { let mut record = state .knowledge .get_by_id(&id) .await .map_err(err_str)? .ok_or_else(|| format!("知识不存在: {id}"))?; // P1 修复(KP-5 审计断档):记录本次实际改动的字段,供 updated 生命线事件审计展示。 // 只在值确实变化时计入 changed_fields(避免无改动也产生 updated 噪音事件)。 let mut changed_fields: Vec<&'static str> = Vec::new(); if let Some(v) = input.title { if record.title != v { changed_fields.push("title"); } record.title = v; } if let Some(v) = input.content { if record.content != v { changed_fields.push("content"); } record.content = v; } if let Some(v) = input.tags { if record.tags.as_deref() != Some(v.as_str()) { changed_fields.push("tags"); } record.tags = Some(v); } // 空串 = 清空(前端编辑器清空 confidence/reasoning 输入框时传 "") if let Some(v) = input.confidence { let new_conf = if v.is_empty() { None } else { Some(v) }; if record.confidence != new_conf { changed_fields.push("confidence"); } record.confidence = new_conf; } if let Some(v) = input.reasoning { let new_reason = if v.is_empty() { None } else { Some(v) }; if record.reasoning != new_reason { changed_fields.push("reasoning"); } record.reasoning = new_reason; } record.updated_at = now_millis(); state .knowledge .update_full(&record) .await .map_err(err_str)?; // 生命线:编辑产生(fire-and-forget)。 // 仅在确有字段改动时记录,无改动不产噪音事件(对齐 create/update_status 的语义粒度)。 // source_ref="ui" 标识编辑来自前端 UI(未来可细分 detail/inbox,当前统一 ui)。 if !changed_fields.is_empty() { KnowledgeTimeline::new(&state.db) .record_updated(&id, &changed_fields, "ui") .await; } Ok(record) } /// 查询生命线事件(可按 event_type 过滤 + limit) #[tauri::command] pub async fn knowledge_events( state: State<'_, AppState>, knowledge_id: String, event_type: Option, limit: Option, ) -> Result, String> { match event_type { Some(et) => { let limit = limit.unwrap_or(50); state .knowledge_events .list_by_knowledge_type(&knowledge_id, &et, limit) .await .map_err(err_str) } None => state .knowledge_events .list_by_knowledge(&knowledge_id) .await .map_err(err_str), } } #[cfg(test)] mod tests { use super::validate_transition; #[test] fn candidate_legal_transitions() { assert!(validate_transition("candidate", "pending_review").is_ok()); assert!(validate_transition("candidate", "published").is_ok()); assert!(validate_transition("candidate", "archived").is_ok()); } #[test] fn candidate_illegal_transitions() { // 禁止自转、回退 assert!(validate_transition("candidate", "candidate").is_err()); } #[test] fn pending_review_legal_transitions() { assert!(validate_transition("pending_review", "published").is_ok()); assert!(validate_transition("pending_review", "archived").is_ok()); // 分池:审核否决走 rejected(独立池),不再混入 archived assert!(validate_transition("pending_review", "rejected").is_ok()); } #[test] fn pending_review_illegal_transitions() { // 已进入审核态不能再退回 candidate assert!(validate_transition("pending_review", "candidate").is_err()); assert!(validate_transition("pending_review", "pending_review").is_err()); } #[test] fn published_legal_transitions() { // 已发布只能归档 assert!(validate_transition("published", "archived").is_ok()); } #[test] fn published_illegal_transitions() { // 发布态不可再进审核、不可回 candidate、不可自转、不可重复 published assert!(validate_transition("published", "pending_review").is_err()); assert!(validate_transition("published", "candidate").is_err()); assert!(validate_transition("published", "published").is_err()); } #[test] fn archived_restore_to_published_legal() { // 归档箱恢复(2026-10-05 归档功能尾巴):archived → published 唯一出边 assert!(validate_transition("archived", "published").is_ok()); } #[test] fn rejected_legal_transitions() { // 分池语义:rejected 唯一出边=回 candidate 重新送审(修改后重审) assert!(validate_transition("candidate", "rejected").is_ok()); assert!(validate_transition("pending_review", "rejected").is_ok()); assert!(validate_transition("rejected", "candidate").is_ok()); } #[test] fn rejected_illegal_transitions() { // 拒绝池不可直接发布(必须走重审 candidate → published)、不可归档/自转 for to in ["published", "pending_review", "archived", "rejected"] { assert!(validate_transition("rejected", to).is_err(), "rejected → {to} 应为非法"); } } #[test] fn archived_illegal_transitions() { // 归档态除恢复发布外均非法(不回 candidate/pending_review,不自转) for to in ["candidate", "pending_review", "archived"] { assert!(validate_transition("archived", to).is_err(), "archived → {to} 应为非法"); } } #[test] fn unknown_from_is_illegal() { // 未知源状态一律拒绝 assert!(validate_transition("unknown", "published").is_err()); assert!(validate_transition("", "archived").is_err()); } #[test] fn illegal_target_is_rejected() { // 合法源 → 未知/空目标一律拒绝 assert!(validate_transition("candidate", "unknown").is_err()); assert!(validate_transition("candidate", "").is_err()); assert!(validate_transition("pending_review", "draft").is_err()); } #[test] fn error_message_contains_transition() { let err = validate_transition("archived", "candidate").unwrap_err(); assert!(err.contains("archived"), "错误信息应包含源状态: {err}"); assert!(err.contains("candidate"), "错误信息应包含目标状态: {err}"); } }