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:
iven
2026-05-19 08:53:29 +08:00
parent 7658bc3cdf
commit c0570dfbfc
2 changed files with 458 additions and 0 deletions

View 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
}
}
}
}

View File

@@ -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;