diff --git a/crates/zclaw-saas/src/knowledge/handlers.rs b/crates/zclaw-saas/src/knowledge/handlers.rs index d985c70..a114f73 100644 --- a/crates/zclaw-saas/src/knowledge/handlers.rs +++ b/crates/zclaw-saas/src/knowledge/handlers.rs @@ -374,21 +374,17 @@ pub async fn rollback_version( // === 检索 === -/// POST /api/v1/knowledge/search — 语义搜索 +/// POST /api/v1/knowledge/search — 统一搜索(双通道:文档 + 结构化) pub async fn search( State(state): State, Extension(ctx): Extension, Json(req): Json, -) -> SaasResult>> { +) -> SaasResult> { check_permission(&ctx, "knowledge:search")?; - let limit = req.limit.unwrap_or(5).min(10); - let min_score = req.min_score.unwrap_or(0.5); - let results = service::search( + let results = service::unified_search( &state.db, - &req.query, - req.category_id.as_deref(), - limit, - min_score, + &req, + Some(&ctx.account_id), ).await?; Ok(Json(results)) } @@ -398,15 +394,15 @@ pub async fn recommend( State(state): State, Extension(ctx): Extension, Json(req): Json, -) -> SaasResult>> { +) -> SaasResult> { check_permission(&ctx, "knowledge:search")?; - let limit = req.limit.unwrap_or(5).min(10); - let results = service::search( + let mut req = req; + req.min_score = Some(0.3); + req.search_structured = req.search_structured.or(Some(true)); + let results = service::unified_search( &state.db, - &req.query, - req.category_id.as_deref(), - limit, - 0.3, + &req, + Some(&ctx.account_id), ).await?; Ok(Json(results)) } @@ -885,3 +881,34 @@ async fn handle_structured_upload( _ => Err(SaasError::InvalidInput("意外的处理结果".into())), } } + +// === 种子知识冷启动 === + +/// POST /api/v1/knowledge/seed — 触发种子知识冷启动 +/// +/// 需要 admin 权限,幂等(按标题+行业查重) +pub async fn seed_knowledge( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult> { + check_permission(&ctx, "knowledge:admin")?; + + if req.items.len() > 100 { + return Err(SaasError::InvalidInput("单次种子不能超过 100 条".into())); + } + + let created = service::seed_knowledge( + &state.db, + &req.industry_id, + req.category_id.as_deref().unwrap_or("seed"), + &req.items.iter().map(|i| (i.title.clone(), i.content.clone(), i.keywords.clone().unwrap_or_default())).collect::>(), + &ctx.account_id, + ).await?; + + Ok(Json(serde_json::json!({ + "industry_id": req.industry_id, + "created_count": created, + "total_submitted": req.items.len(), + }))) +} diff --git a/crates/zclaw-saas/src/knowledge/mod.rs b/crates/zclaw-saas/src/knowledge/mod.rs index 658a8bd..e510870 100644 --- a/crates/zclaw-saas/src/knowledge/mod.rs +++ b/crates/zclaw-saas/src/knowledge/mod.rs @@ -32,6 +32,7 @@ pub fn routes() -> axum::Router { // 检索 .route("/api/v1/knowledge/search", post(handlers::search)) .route("/api/v1/knowledge/recommend", post(handlers::recommend)) + .route("/api/v1/knowledge/seed", post(handlers::seed_knowledge)) // 分析看板 .route("/api/v1/knowledge/analytics/overview", get(handlers::analytics_overview)) .route("/api/v1/knowledge/analytics/trends", get(handlers::analytics_trends)) diff --git a/crates/zclaw-saas/src/knowledge/service.rs b/crates/zclaw-saas/src/knowledge/service.rs index 81428d9..db0fbbf 100644 --- a/crates/zclaw-saas/src/knowledge/service.rs +++ b/crates/zclaw-saas/src/knowledge/service.rs @@ -582,6 +582,113 @@ pub async fn search( }).filter(|r| r.score >= min_score).collect()) } +// === 统一搜索(双通道合并) === + +/// 统一搜索:同时检索文档通道和结构化通道 +pub async fn unified_search( + pool: &PgPool, + request: &SearchRequest, + viewer_account_id: Option<&str>, +) -> SaasResult { + let limit = request.limit.unwrap_or(5).min(10); + let search_docs = request.search_documents.unwrap_or(true); + let search_struct = request.search_structured.unwrap_or(true); + + // 文档通道 + let documents = if search_docs { + search( + pool, + &request.query, + request.category_id.as_deref(), + limit, + request.min_score.unwrap_or(0.5), + ).await? + } else { + Vec::new() + }; + + // 结构化通道 + let structured = if search_struct { + query_structured( + pool, + &StructuredQueryRequest { + query: request.query.clone(), + source_id: None, + industry_id: request.industry_id.clone(), + limit: Some(limit), + }, + viewer_account_id, + ).await? + } else { + Vec::new() + }; + + Ok(UnifiedSearchResult { + documents, + structured, + }) +} + +// === 种子知识冷启动 === + +/// 为指定行业插入种子知识(幂等) +pub async fn seed_knowledge( + pool: &PgPool, + industry_id: &str, + category_id: &str, + items: &[(String, String, Vec)], // (title, content, keywords) + system_account_id: &str, +) -> SaasResult { + let mut created = 0; + for (title, content, keywords) in items { + if content.trim().is_empty() { + continue; + } + // 幂等:按标题 + source='distillation' + tags 含行业ID 查重 + let exists: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM knowledge_items \ + WHERE title = $1 AND source = 'distillation' \ + AND $2 = ANY(tags)" + ) + .bind(title) + .bind(format!("industry:{}", industry_id)) + .fetch_one(pool) + .await?; + + if exists.0 > 0 { + continue; + } + + let id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now(); + let kw_json = serde_json::to_value(keywords).unwrap_or(serde_json::json!([])); + let tags = vec![ + format!("industry:{}", industry_id), + "source:distillation".to_string(), + ]; + + sqlx::query( + "INSERT INTO knowledge_items \ + (id, category_id, title, content, keywords, status, priority, visibility, account_id, source, tags, version, created_by, created_at, updated_at) \ + VALUES ($1, $8, $2, $3, $4, 'active', 5, 'public', NULL, \ + 'distillation', $5, 1, $6, $7, $7)" + ) + .bind(&id) + .bind(title) + .bind(content) + .bind(&kw_json) + .bind(&tags) + .bind(system_account_id) + .bind(&now) + .bind(category_id) + .execute(pool) + .await?; + + created += 1; + } + Ok(created) +} + // === 分析 === /// 分析总览 diff --git a/crates/zclaw-saas/src/knowledge/types.rs b/crates/zclaw-saas/src/knowledge/types.rs index fc4a856..6e3bb5c 100644 --- a/crates/zclaw-saas/src/knowledge/types.rs +++ b/crates/zclaw-saas/src/knowledge/types.rs @@ -331,3 +331,19 @@ pub struct StructuredQueryResult { pub total_matched: i64, pub generated_sql: Option, } + +// === 种子知识 === + +#[derive(Debug, Deserialize)] +pub struct SeedKnowledgeRequest { + pub industry_id: String, + pub category_id: Option, + pub items: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct SeedKnowledgeItem { + pub title: String, + pub content: String, + pub keywords: Option>, +}