feat(knowledge): Phase D 统一搜索 + 种子知识冷启动
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- search/recommend API 返回 UnifiedSearchResult (文档+结构化双通道) - POST /api/v1/knowledge/seed 种子知识冷启动 (幂等, admin权限) - seed_knowledge service: 按标题+行业查重, source=distillation - SearchRequest 扩展: search_structured/search_documents/industry_id
This commit is contained in:
@@ -374,21 +374,17 @@ pub async fn rollback_version(
|
|||||||
|
|
||||||
// === 检索 ===
|
// === 检索 ===
|
||||||
|
|
||||||
/// POST /api/v1/knowledge/search — 语义搜索
|
/// POST /api/v1/knowledge/search — 统一搜索(双通道:文档 + 结构化)
|
||||||
pub async fn search(
|
pub async fn search(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(ctx): Extension<AuthContext>,
|
Extension(ctx): Extension<AuthContext>,
|
||||||
Json(req): Json<SearchRequest>,
|
Json(req): Json<SearchRequest>,
|
||||||
) -> SaasResult<Json<Vec<SearchResult>>> {
|
) -> SaasResult<Json<UnifiedSearchResult>> {
|
||||||
check_permission(&ctx, "knowledge:search")?;
|
check_permission(&ctx, "knowledge:search")?;
|
||||||
let limit = req.limit.unwrap_or(5).min(10);
|
let results = service::unified_search(
|
||||||
let min_score = req.min_score.unwrap_or(0.5);
|
|
||||||
let results = service::search(
|
|
||||||
&state.db,
|
&state.db,
|
||||||
&req.query,
|
&req,
|
||||||
req.category_id.as_deref(),
|
Some(&ctx.account_id),
|
||||||
limit,
|
|
||||||
min_score,
|
|
||||||
).await?;
|
).await?;
|
||||||
Ok(Json(results))
|
Ok(Json(results))
|
||||||
}
|
}
|
||||||
@@ -398,15 +394,15 @@ pub async fn recommend(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(ctx): Extension<AuthContext>,
|
Extension(ctx): Extension<AuthContext>,
|
||||||
Json(req): Json<SearchRequest>,
|
Json(req): Json<SearchRequest>,
|
||||||
) -> SaasResult<Json<Vec<SearchResult>>> {
|
) -> SaasResult<Json<UnifiedSearchResult>> {
|
||||||
check_permission(&ctx, "knowledge:search")?;
|
check_permission(&ctx, "knowledge:search")?;
|
||||||
let limit = req.limit.unwrap_or(5).min(10);
|
let mut req = req;
|
||||||
let results = service::search(
|
req.min_score = Some(0.3);
|
||||||
|
req.search_structured = req.search_structured.or(Some(true));
|
||||||
|
let results = service::unified_search(
|
||||||
&state.db,
|
&state.db,
|
||||||
&req.query,
|
&req,
|
||||||
req.category_id.as_deref(),
|
Some(&ctx.account_id),
|
||||||
limit,
|
|
||||||
0.3,
|
|
||||||
).await?;
|
).await?;
|
||||||
Ok(Json(results))
|
Ok(Json(results))
|
||||||
}
|
}
|
||||||
@@ -885,3 +881,34 @@ async fn handle_structured_upload(
|
|||||||
_ => Err(SaasError::InvalidInput("意外的处理结果".into())),
|
_ => Err(SaasError::InvalidInput("意外的处理结果".into())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 种子知识冷启动 ===
|
||||||
|
|
||||||
|
/// POST /api/v1/knowledge/seed — 触发种子知识冷启动
|
||||||
|
///
|
||||||
|
/// 需要 admin 权限,幂等(按标题+行业查重)
|
||||||
|
pub async fn seed_knowledge(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(ctx): Extension<AuthContext>,
|
||||||
|
Json(req): Json<SeedKnowledgeRequest>,
|
||||||
|
) -> SaasResult<Json<serde_json::Value>> {
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
&ctx.account_id,
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"industry_id": req.industry_id,
|
||||||
|
"created_count": created,
|
||||||
|
"total_submitted": req.items.len(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ pub fn routes() -> axum::Router<crate::state::AppState> {
|
|||||||
// 检索
|
// 检索
|
||||||
.route("/api/v1/knowledge/search", post(handlers::search))
|
.route("/api/v1/knowledge/search", post(handlers::search))
|
||||||
.route("/api/v1/knowledge/recommend", post(handlers::recommend))
|
.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/overview", get(handlers::analytics_overview))
|
||||||
.route("/api/v1/knowledge/analytics/trends", get(handlers::analytics_trends))
|
.route("/api/v1/knowledge/analytics/trends", get(handlers::analytics_trends))
|
||||||
|
|||||||
@@ -582,6 +582,113 @@ pub async fn search(
|
|||||||
}).filter(|r| r.score >= min_score).collect())
|
}).filter(|r| r.score >= min_score).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 统一搜索(双通道合并) ===
|
||||||
|
|
||||||
|
/// 统一搜索:同时检索文档通道和结构化通道
|
||||||
|
pub async fn unified_search(
|
||||||
|
pool: &PgPool,
|
||||||
|
request: &SearchRequest,
|
||||||
|
viewer_account_id: Option<&str>,
|
||||||
|
) -> SaasResult<UnifiedSearchResult> {
|
||||||
|
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<String>)], // (title, content, keywords)
|
||||||
|
system_account_id: &str,
|
||||||
|
) -> SaasResult<usize> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// === 分析 ===
|
// === 分析 ===
|
||||||
|
|
||||||
/// 分析总览
|
/// 分析总览
|
||||||
|
|||||||
@@ -331,3 +331,19 @@ pub struct StructuredQueryResult {
|
|||||||
pub total_matched: i64,
|
pub total_matched: i64,
|
||||||
pub generated_sql: Option<String>,
|
pub generated_sql: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 种子知识 ===
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SeedKnowledgeRequest {
|
||||||
|
pub industry_id: String,
|
||||||
|
pub category_id: Option<String>,
|
||||||
|
pub items: Vec<SeedKnowledgeItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SeedKnowledgeItem {
|
||||||
|
pub title: String,
|
||||||
|
pub content: String,
|
||||||
|
pub keywords: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user