From c0570dfbfca4f906e9f5610b2470dc003ddec17f Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 19 May 2026 08:53:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20Phase=203A-3=20=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E5=BA=93=20CRUD=20=E6=9C=8D=E5=8A=A1=20=E2=80=94=20references/?= =?UTF-8?q?guides=20=E5=88=9B=E5=BB=BA/=E6=9B=B4=E6=96=B0/=E5=88=A0?= =?UTF-8?q?=E9=99=A4/=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KnowledgeService: 完整 CRUD(创建含自动 embedding 生成) - Embedding 失败时降级为 NULL(条目仍可 CRUD,但不可向量搜索) - re_embed 方法支持单条重新生成向量 - 所有操作通过 raw SQL 写入 pgvector embedding 列 - 软删除 + tenant_id 隔离 --- crates/erp-ai/src/service/knowledge.rs | 457 +++++++++++++++++++++++++ crates/erp-ai/src/service/mod.rs | 1 + 2 files changed, 458 insertions(+) create mode 100644 crates/erp-ai/src/service/knowledge.rs diff --git a/crates/erp-ai/src/service/knowledge.rs b/crates/erp-ai/src/service/knowledge.rs new file mode 100644 index 0000000..ca7d330 --- /dev/null +++ b/crates/erp-ai/src/service/knowledge.rs @@ -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, + pub is_enabled: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateKnowledgeReferenceReq { + pub title: Option, + pub analysis_type: Option, + pub source_name: Option, + pub content_summary: Option, + pub tags: Option, + pub is_enabled: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateKnowledgeGuideReq { + pub title: String, + pub analysis_type: String, + pub content: String, + pub category: Option, + pub is_enabled: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateKnowledgeGuideReq { + pub title: Option, + pub analysis_type: Option, + pub content: Option, + pub category: Option, + pub is_enabled: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ListKnowledgeQuery { + pub analysis_type: Option, + pub page: Option, + pub page_size: Option, +} + +// ─── Service ─── + +pub struct KnowledgeService { + db: sea_orm::DatabaseConnection, + embedding: Arc, +} + +impl KnowledgeService { + pub fn new(db: sea_orm::DatabaseConnection, embedding: Arc) -> Self { + Self { db, embedding } + } + + // ─── References CRUD ─── + + pub async fn list_references( + &self, + tenant_id: Uuid, + query: &ListKnowledgeQuery, + ) -> AiResult> { + 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 { + 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::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> { + 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 { + 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::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> { + 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 + } + } + } +} diff --git a/crates/erp-ai/src/service/mod.rs b/crates/erp-ai/src/service/mod.rs index e61ebd8..2814964 100644 --- a/crates/erp-ai/src/service/mod.rs +++ b/crates/erp-ai/src/service/mod.rs @@ -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;