新增: Phase2 阶段收尾(Sprint 1-20)
重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录 功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore 修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸 文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新 详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
//! 知识库相关命令 — 共享记忆层(沉淀 / 检索注入 / 审核收件箱 / 配置)
|
||||
//!
|
||||
//! 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_core::types::new_id;
|
||||
use df_storage::models::{KnowledgeEventRecord, KnowledgeRecord};
|
||||
|
||||
use crate::state::{AppState, KnowledgeConfig};
|
||||
|
||||
use super::knowledge_timeline::KnowledgeTimeline;
|
||||
use super::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<String>,
|
||||
#[serde(default)]
|
||||
pub source_project: Option<String>,
|
||||
#[serde(default)]
|
||||
pub source_ref: Option<String>,
|
||||
/// high | medium | low
|
||||
#[serde(default)]
|
||||
pub confidence: Option<String>,
|
||||
}
|
||||
|
||||
/// 检索入参
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct KnowledgeSearchInput {
|
||||
pub query: String,
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// 状态转换合法矩阵校验
|
||||
///
|
||||
/// 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 时默认排除 archived
|
||||
#[tauri::command]
|
||||
pub async fn knowledge_list(
|
||||
state: State<'_, AppState>,
|
||||
status: Option<String>,
|
||||
) -> Result<Vec<KnowledgeRecord>, String> {
|
||||
match status {
|
||||
// 显式查 archived 时原样返回(含归档项)
|
||||
Some(s) if s == "archived" => state.knowledge.list_by_status("archived").await.map_err(|e| e.to_string()),
|
||||
Some(s) => state.knowledge.list_by_status(&s).await.map_err(|e| e.to_string()),
|
||||
// 默认:列出非 archived 的全部(单查询 status != 'archived')
|
||||
None => state.knowledge.list_non_archived().await.map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条查询
|
||||
#[tauri::command]
|
||||
pub async fn knowledge_get(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<KnowledgeRecord, String> {
|
||||
state
|
||||
.knowledge
|
||||
.get_by_id(&id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("知识不存在: {id}"))
|
||||
}
|
||||
|
||||
/// 检索知识(top-N,默认 3) — MCP search tool
|
||||
#[tauri::command]
|
||||
pub async fn knowledge_search(
|
||||
state: State<'_, AppState>,
|
||||
input: KnowledgeSearchInput,
|
||||
) -> Result<Vec<KnowledgeRecord>, String> {
|
||||
let limit = input.limit.unwrap_or(3).min(3);
|
||||
state
|
||||
.knowledge
|
||||
.search(&input.query, input.kind.as_deref(), limit)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 创建知识(始终 candidate 状态) — MCP create tool
|
||||
#[tauri::command]
|
||||
pub async fn knowledge_create(
|
||||
state: State<'_, AppState>,
|
||||
input: CreateKnowledgeInput,
|
||||
) -> Result<KnowledgeRecord, String> {
|
||||
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,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
state
|
||||
.knowledge
|
||||
.insert(record.clone())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// 生命线:手动录入产生(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<bool, String> {
|
||||
let record = state
|
||||
.knowledge
|
||||
.get_by_id(&id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.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(|e| e.to_string())?;
|
||||
|
||||
// 生命线:状态变更(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<bool, String> {
|
||||
state
|
||||
.knowledge
|
||||
.increment_reuse_count(&id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 审核收件箱 — 列出 candidate(按 confidence 语义排序)
|
||||
#[tauri::command]
|
||||
pub async fn knowledge_list_candidates(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<KnowledgeRecord>, String> {
|
||||
state
|
||||
.knowledge
|
||||
.list_by_status("candidate")
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 归档(软删除) — UPDATE status='archived'
|
||||
#[tauri::command]
|
||||
pub async fn knowledge_archive(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<bool, String> {
|
||||
knowledge_update_status(state, id, "archived".to_string()).await
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 配置 + 手动提炼
|
||||
// ============================================================
|
||||
|
||||
/// 读取知识库行为配置(提取 + 注入)
|
||||
#[tauri::command]
|
||||
pub async fn knowledge_get_config(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<KnowledgeConfig, String> {
|
||||
let cfg = state.knowledge_config.lock().await;
|
||||
Ok(cfg.clone())
|
||||
}
|
||||
|
||||
/// 保存知识库行为配置
|
||||
#[tauri::command]
|
||||
pub async fn knowledge_save_config(
|
||||
state: State<'_, AppState>,
|
||||
config: KnowledgeConfig,
|
||||
) -> Result<bool, String> {
|
||||
let mut cfg = state.knowledge_config.lock().await;
|
||||
*cfg = config;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 手动触发提炼(ManualOnly 模式 / 用户主动点按钮)
|
||||
///
|
||||
/// 实际提炼逻辑在 commands::ai 模块(需访问 active provider + conversation messages)。
|
||||
/// 此 command 仅作前端入口,委托给 ai 模块的提炼函数。
|
||||
#[tauri::command]
|
||||
pub async fn knowledge_extract_now(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
crate::commands::ai::trigger_extraction_now(&state).await
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 生命线:详情 / 编辑 / 事件查询
|
||||
// ============================================================
|
||||
|
||||
/// 编辑知识入参(部分更新,仅传需改字段)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateKnowledgeInput {
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub content: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Option<String>,
|
||||
#[serde(default)]
|
||||
pub confidence: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reasoning: Option<String>,
|
||||
}
|
||||
|
||||
/// 知识详情聚合负载(基本信息 + 全部生命线事件)
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct KnowledgeDetailPayload {
|
||||
pub knowledge: KnowledgeRecord,
|
||||
pub events: Vec<KnowledgeEventRecord>,
|
||||
}
|
||||
|
||||
/// 知识详情(基本信息 + 生命线事件) — 详情页一次拉全
|
||||
#[tauri::command]
|
||||
pub async fn knowledge_get_detail(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<KnowledgeDetailPayload, String> {
|
||||
let knowledge = state
|
||||
.knowledge
|
||||
.get_by_id(&id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("知识不存在: {id}"))?;
|
||||
let events = state
|
||||
.knowledge_events
|
||||
.list_by_knowledge(&id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(KnowledgeDetailPayload { knowledge, events })
|
||||
}
|
||||
|
||||
/// 编辑知识(部分更新 title/content/tags/confidence/reasoning)
|
||||
#[tauri::command]
|
||||
pub async fn knowledge_update(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
input: UpdateKnowledgeInput,
|
||||
) -> Result<KnowledgeRecord, String> {
|
||||
let mut record = state
|
||||
.knowledge
|
||||
.get_by_id(&id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("知识不存在: {id}"))?;
|
||||
if let Some(v) = input.title {
|
||||
record.title = v;
|
||||
}
|
||||
if let Some(v) = input.content {
|
||||
record.content = v;
|
||||
}
|
||||
if let Some(v) = input.tags {
|
||||
record.tags = Some(v);
|
||||
}
|
||||
// 空串 = 清空(前端编辑器清空 confidence/reasoning 输入框时传 "")
|
||||
if let Some(v) = input.confidence {
|
||||
record.confidence = if v.is_empty() { None } else { Some(v) };
|
||||
}
|
||||
if let Some(v) = input.reasoning {
|
||||
record.reasoning = if v.is_empty() { None } else { Some(v) };
|
||||
}
|
||||
record.updated_at = now_millis();
|
||||
state
|
||||
.knowledge
|
||||
.update_full(&record)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// 查询生命线事件(可按 event_type 过滤 + limit)
|
||||
#[tauri::command]
|
||||
pub async fn knowledge_events(
|
||||
state: State<'_, AppState>,
|
||||
knowledge_id: String,
|
||||
event_type: Option<String>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<KnowledgeEventRecord>, 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(|e| e.to_string())
|
||||
}
|
||||
None => state
|
||||
.knowledge_events
|
||||
.list_by_knowledge(&knowledge_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user