feat(ai): Phase 3A-3 知识库 CRUD 服务 — references/guides 创建/更新/删除/列表
- KnowledgeService: 完整 CRUD(创建含自动 embedding 生成) - Embedding 失败时降级为 NULL(条目仍可 CRUD,但不可向量搜索) - re_embed 方法支持单条重新生成向量 - 所有操作通过 raw SQL 写入 pgvector embedding 列 - 软删除 + tenant_id 隔离
This commit is contained in:
457
crates/erp-ai/src/service/knowledge.rs
Normal file
457
crates/erp-ai/src/service/knowledge.rs
Normal file
@@ -0,0 +1,457 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::{ai_knowledge_guides, ai_knowledge_references};
|
||||
use crate::error::{AiError, AiResult};
|
||||
use crate::service::embedding::{EmbeddingService, format_vector};
|
||||
|
||||
// ─── DTO ───
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct CreateKnowledgeReferenceReq {
|
||||
pub title: String,
|
||||
pub analysis_type: String,
|
||||
pub source_name: String,
|
||||
pub content_summary: String,
|
||||
pub tags: Option<serde_json::Value>,
|
||||
pub is_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct UpdateKnowledgeReferenceReq {
|
||||
pub title: Option<String>,
|
||||
pub analysis_type: Option<String>,
|
||||
pub source_name: Option<String>,
|
||||
pub content_summary: Option<String>,
|
||||
pub tags: Option<serde_json::Value>,
|
||||
pub is_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct CreateKnowledgeGuideReq {
|
||||
pub title: String,
|
||||
pub analysis_type: String,
|
||||
pub content: String,
|
||||
pub category: Option<String>,
|
||||
pub is_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct UpdateKnowledgeGuideReq {
|
||||
pub title: Option<String>,
|
||||
pub analysis_type: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub is_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListKnowledgeQuery {
|
||||
pub analysis_type: Option<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
// ─── Service ───
|
||||
|
||||
pub struct KnowledgeService {
|
||||
db: sea_orm::DatabaseConnection,
|
||||
embedding: Arc<EmbeddingService>,
|
||||
}
|
||||
|
||||
impl KnowledgeService {
|
||||
pub fn new(db: sea_orm::DatabaseConnection, embedding: Arc<EmbeddingService>) -> Self {
|
||||
Self { db, embedding }
|
||||
}
|
||||
|
||||
// ─── References CRUD ───
|
||||
|
||||
pub async fn list_references(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
query: &ListKnowledgeQuery,
|
||||
) -> AiResult<Vec<ai_knowledge_references::Model>> {
|
||||
let mut q = ai_knowledge_references::Entity::find()
|
||||
.filter(ai_knowledge_references::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_knowledge_references::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(ref at) = query.analysis_type {
|
||||
q = q.filter(ai_knowledge_references::Column::AnalysisType.eq(at.as_str()));
|
||||
}
|
||||
|
||||
Ok(q.all(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn create_reference(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
req: CreateKnowledgeReferenceReq,
|
||||
) -> AiResult<Uuid> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let embed_text = format!("{} {}", req.title, req.content_summary);
|
||||
let embedding = self.try_embed(&embed_text).await;
|
||||
|
||||
let sql = r#"
|
||||
INSERT INTO ai_knowledge_references
|
||||
(id, tenant_id, title, analysis_type, source_name, content_summary, embedding, tags, is_enabled,
|
||||
created_at, updated_at, created_by, updated_by, deleted_at, version_lock)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::vector, $8, $9, $10, $10, $11, $11, NULL, 1)
|
||||
"#;
|
||||
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[
|
||||
sea_orm::Value::from(id),
|
||||
sea_orm::Value::from(tenant_id),
|
||||
sea_orm::Value::String(Some(Box::new(req.title))),
|
||||
sea_orm::Value::String(Some(Box::new(req.analysis_type))),
|
||||
sea_orm::Value::String(Some(Box::new(req.source_name))),
|
||||
sea_orm::Value::String(Some(Box::new(req.content_summary))),
|
||||
embedding
|
||||
.as_ref()
|
||||
.map(|e| sea_orm::Value::String(Some(Box::new(format_vector(e)))))
|
||||
.unwrap_or(sea_orm::Value::String(None)),
|
||||
req.tags
|
||||
.map(|t| sea_orm::Value::Json(Some(Box::new(t))))
|
||||
.unwrap_or(sea_orm::Value::Json(None)),
|
||||
sea_orm::Value::from(req.is_enabled.unwrap_or(true)),
|
||||
sea_orm::Value::from(now),
|
||||
sea_orm::Value::from(user_id),
|
||||
],
|
||||
);
|
||||
self.db
|
||||
.execute(stmt)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn update_reference(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
id: Uuid,
|
||||
req: UpdateKnowledgeReferenceReq,
|
||||
) -> AiResult<()> {
|
||||
let existing = self.find_reference(tenant_id, id).await?;
|
||||
|
||||
let title = req.title.unwrap_or(existing.title);
|
||||
let analysis_type = req.analysis_type.unwrap_or(existing.analysis_type);
|
||||
let source_name = req.source_name.unwrap_or(existing.source_name);
|
||||
let content_summary = req.content_summary.unwrap_or(existing.content_summary);
|
||||
let tags = req.tags.or(existing.tags);
|
||||
let is_enabled = req.is_enabled.unwrap_or(existing.is_enabled);
|
||||
|
||||
let embed_text = format!("{} {}", title, content_summary);
|
||||
let embedding = self.try_embed(&embed_text).await;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let sql = r#"
|
||||
UPDATE ai_knowledge_references
|
||||
SET title = $3, analysis_type = $4, source_name = $5, content_summary = $6,
|
||||
embedding = $7::vector, tags = $8, is_enabled = $9,
|
||||
updated_at = $10, updated_by = $11, version_lock = version_lock + 1
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
"#;
|
||||
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[
|
||||
sea_orm::Value::from(id),
|
||||
sea_orm::Value::from(tenant_id),
|
||||
sea_orm::Value::String(Some(Box::new(title))),
|
||||
sea_orm::Value::String(Some(Box::new(analysis_type))),
|
||||
sea_orm::Value::String(Some(Box::new(source_name))),
|
||||
sea_orm::Value::String(Some(Box::new(content_summary))),
|
||||
embedding
|
||||
.map(|e| sea_orm::Value::String(Some(Box::new(format_vector(&e)))))
|
||||
.unwrap_or(sea_orm::Value::String(None)),
|
||||
tags.map(|t| sea_orm::Value::Json(Some(Box::new(t))))
|
||||
.unwrap_or(sea_orm::Value::Json(None)),
|
||||
sea_orm::Value::from(is_enabled),
|
||||
sea_orm::Value::from(now),
|
||||
sea_orm::Value::from(user_id),
|
||||
],
|
||||
);
|
||||
self.db
|
||||
.execute(stmt)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_reference(&self, tenant_id: Uuid, id: Uuid) -> AiResult<()> {
|
||||
let now = chrono::Utc::now();
|
||||
let sql = r#"
|
||||
UPDATE ai_knowledge_references
|
||||
SET deleted_at = $3, version_lock = version_lock + 1
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
"#;
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[
|
||||
sea_orm::Value::from(id),
|
||||
sea_orm::Value::from(tenant_id),
|
||||
sea_orm::Value::from(now),
|
||||
],
|
||||
);
|
||||
self.db
|
||||
.execute(stmt)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn re_embed_reference(&self, tenant_id: Uuid, id: Uuid) -> AiResult<()> {
|
||||
let existing = self.find_reference(tenant_id, id).await?;
|
||||
let embed_text = format!("{} {}", existing.title, existing.content_summary);
|
||||
let embedding = self.embedding.embed(&embed_text).await?;
|
||||
let vector_str = format_vector(&embedding);
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let sql = r#"
|
||||
UPDATE ai_knowledge_references
|
||||
SET embedding = $3::vector, updated_at = $4, version_lock = version_lock + 1
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
"#;
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[
|
||||
sea_orm::Value::from(id),
|
||||
sea_orm::Value::from(tenant_id),
|
||||
sea_orm::Value::String(Some(Box::new(vector_str))),
|
||||
sea_orm::Value::from(now),
|
||||
],
|
||||
);
|
||||
self.db
|
||||
.execute(stmt)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_reference(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
id: Uuid,
|
||||
) -> AiResult<ai_knowledge_references::Model> {
|
||||
ai_knowledge_references::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| AiError::KnowledgeError("参考资料不存在".into()))
|
||||
}
|
||||
|
||||
// ─── Guides CRUD ───
|
||||
|
||||
pub async fn list_guides(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
query: &ListKnowledgeQuery,
|
||||
) -> AiResult<Vec<ai_knowledge_guides::Model>> {
|
||||
let mut q = ai_knowledge_guides::Entity::find()
|
||||
.filter(ai_knowledge_guides::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_knowledge_guides::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(ref at) = query.analysis_type {
|
||||
q = q.filter(ai_knowledge_guides::Column::AnalysisType.eq(at.as_str()));
|
||||
}
|
||||
|
||||
Ok(q.all(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn create_guide(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
req: CreateKnowledgeGuideReq,
|
||||
) -> AiResult<Uuid> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let embed_text = format!("{} {}", req.title, req.content);
|
||||
let embedding = self.try_embed(&embed_text).await;
|
||||
|
||||
let sql = r#"
|
||||
INSERT INTO ai_knowledge_guides
|
||||
(id, tenant_id, title, analysis_type, content, category, embedding, is_enabled,
|
||||
created_at, updated_at, created_by, updated_by, deleted_at, version_lock)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::vector, $8, $9, $9, $10, $10, NULL, 1)
|
||||
"#;
|
||||
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[
|
||||
sea_orm::Value::from(id),
|
||||
sea_orm::Value::from(tenant_id),
|
||||
sea_orm::Value::String(Some(Box::new(req.title))),
|
||||
sea_orm::Value::String(Some(Box::new(req.analysis_type))),
|
||||
sea_orm::Value::String(Some(Box::new(req.content))),
|
||||
req.category
|
||||
.map(|c| sea_orm::Value::String(Some(Box::new(c))))
|
||||
.unwrap_or(sea_orm::Value::String(None)),
|
||||
embedding
|
||||
.as_ref()
|
||||
.map(|e| sea_orm::Value::String(Some(Box::new(format_vector(e)))))
|
||||
.unwrap_or(sea_orm::Value::String(None)),
|
||||
sea_orm::Value::from(req.is_enabled.unwrap_or(true)),
|
||||
sea_orm::Value::from(now),
|
||||
sea_orm::Value::from(user_id),
|
||||
],
|
||||
);
|
||||
self.db
|
||||
.execute(stmt)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn update_guide(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
id: Uuid,
|
||||
req: UpdateKnowledgeGuideReq,
|
||||
) -> AiResult<()> {
|
||||
let existing = self.find_guide(tenant_id, id).await?;
|
||||
|
||||
let title = req.title.unwrap_or(existing.title);
|
||||
let analysis_type = req.analysis_type.unwrap_or(existing.analysis_type);
|
||||
let content = req.content.unwrap_or(existing.content);
|
||||
let category = req.category.or(existing.category);
|
||||
let is_enabled = req.is_enabled.unwrap_or(existing.is_enabled);
|
||||
|
||||
let embed_text = format!("{} {}", title, content);
|
||||
let embedding = self.try_embed(&embed_text).await;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let sql = r#"
|
||||
UPDATE ai_knowledge_guides
|
||||
SET title = $3, analysis_type = $4, content = $5, category = $6,
|
||||
embedding = $7::vector, is_enabled = $8,
|
||||
updated_at = $9, updated_by = $10, version_lock = version_lock + 1
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
"#;
|
||||
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[
|
||||
sea_orm::Value::from(id),
|
||||
sea_orm::Value::from(tenant_id),
|
||||
sea_orm::Value::String(Some(Box::new(title))),
|
||||
sea_orm::Value::String(Some(Box::new(analysis_type))),
|
||||
sea_orm::Value::String(Some(Box::new(content))),
|
||||
category
|
||||
.map(|c| sea_orm::Value::String(Some(Box::new(c))))
|
||||
.unwrap_or(sea_orm::Value::String(None)),
|
||||
embedding
|
||||
.map(|e| sea_orm::Value::String(Some(Box::new(format_vector(&e)))))
|
||||
.unwrap_or(sea_orm::Value::String(None)),
|
||||
sea_orm::Value::from(is_enabled),
|
||||
sea_orm::Value::from(now),
|
||||
sea_orm::Value::from(user_id),
|
||||
],
|
||||
);
|
||||
self.db
|
||||
.execute(stmt)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_guide(&self, tenant_id: Uuid, id: Uuid) -> AiResult<()> {
|
||||
let now = chrono::Utc::now();
|
||||
let sql = r#"
|
||||
UPDATE ai_knowledge_guides
|
||||
SET deleted_at = $3, version_lock = version_lock + 1
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
"#;
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[
|
||||
sea_orm::Value::from(id),
|
||||
sea_orm::Value::from(tenant_id),
|
||||
sea_orm::Value::from(now),
|
||||
],
|
||||
);
|
||||
self.db
|
||||
.execute(stmt)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn re_embed_guide(&self, tenant_id: Uuid, id: Uuid) -> AiResult<()> {
|
||||
let existing = self.find_guide(tenant_id, id).await?;
|
||||
let embed_text = format!("{} {}", existing.title, existing.content);
|
||||
let embedding = self.embedding.embed(&embed_text).await?;
|
||||
let vector_str = format_vector(&embedding);
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let sql = r#"
|
||||
UPDATE ai_knowledge_guides
|
||||
SET embedding = $3::vector, updated_at = $4, version_lock = version_lock + 1
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
"#;
|
||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[
|
||||
sea_orm::Value::from(id),
|
||||
sea_orm::Value::from(tenant_id),
|
||||
sea_orm::Value::String(Some(Box::new(vector_str))),
|
||||
sea_orm::Value::from(now),
|
||||
],
|
||||
);
|
||||
self.db
|
||||
.execute(stmt)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_guide(&self, tenant_id: Uuid, id: Uuid) -> AiResult<ai_knowledge_guides::Model> {
|
||||
ai_knowledge_guides::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await
|
||||
.map_err(|e| AiError::DbError(e.to_string()))?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| AiError::KnowledgeError("临床指南不存在".into()))
|
||||
}
|
||||
|
||||
async fn try_embed(&self, text: &str) -> Option<Vec<f32>> {
|
||||
if !self.embedding.is_configured() {
|
||||
tracing::debug!("Embedding API 未配置,跳过向量生成");
|
||||
return None;
|
||||
}
|
||||
match self.embedding.embed(text).await {
|
||||
Ok(e) => Some(e),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Embedding 生成失败,将创建无向量条目");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod dialysis_risk_scorer;
|
||||
pub mod embedding;
|
||||
pub mod feature_flag_service;
|
||||
pub mod insight_service;
|
||||
pub mod knowledge;
|
||||
pub mod local_rules;
|
||||
pub mod output_parser;
|
||||
pub mod post_process;
|
||||
|
||||
Reference in New Issue
Block a user