//! 知识库相关命令 — 共享记忆层(沉淀 / 检索注入 / 审核收件箱 / 配置) //! //! 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}; 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 | archived /// pending_review → published | archived /// published → archived /// (其他组合非法) fn validate_transition(from: &str, to: &str) -> Result<(), String> { let legal = match from { "candidate" => matches!(to, "pending_review" | "published" | "archived"), "pending_review" => matches!(to, "published" | "archived"), "published" => matches!(to, "archived"), _ => 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) } // ============================================================ // 配置 + 手动提炼 // ============================================================ /// 读取知识库行为配置(提取 + 注入) #[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, } /// 知识详情(基本信息 + 生命线事件) — 详情页一次拉全 #[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)?; Ok(KnowledgeDetailPayload { knowledge, events }) } /// 编辑知识(部分更新 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()); } #[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_is_terminal() { // 归档为终态,任何转换都非法 for to in ["candidate", "pending_review", "published", "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}"); } }