feat(ai): Phase 3A RAG 知识库 — CRUD API + Agent Tool + 向量知识源 + 前端管理页

- 知识库 REST API: 10 个端点 (references/guides CRUD + re-embed)
- search_medical_knowledge Agent Tool: 语义检索参考资料和临床指南
- VectorKnowledgeSource: 实现 KnowledgeSource trait,自动降级
- 沙箱配置: Patient/MedicalStaff 允许使用知识库检索
- 前端 AiKnowledgePage: Tabs(参考资料/临床指南) + Table + Modal CRUD
- 权限码 seed 迁移: ai.knowledge.list + ai.knowledge.manage + 菜单
This commit is contained in:
iven
2026-05-19 09:10:53 +08:00
parent c0570dfbfc
commit 8b88cb4a50
18 changed files with 1389 additions and 5 deletions

View File

@@ -44,6 +44,7 @@ pub fn get_sandbox_config(role: &UserRole) -> SandboxConfig {
"query_patient_vitals".into(),
"query_patient_lab_reports".into(),
"query_patient_medications".into(),
"search_medical_knowledge".into(),
]),
system_prompt_suffix: PATIENT_PROMPT_SUFFIX,
output_filter: OutputFilter {
@@ -59,6 +60,7 @@ pub fn get_sandbox_config(role: &UserRole) -> SandboxConfig {
"query_patient_lab_reports".into(),
"query_patient_appointments".into(),
"query_patient_medications".into(),
"search_medical_knowledge".into(),
]),
system_prompt_suffix: MEDICAL_STAFF_PROMPT_SUFFIX,
output_filter: OutputFilter {

View File

@@ -4,8 +4,10 @@ pub mod query_appointments;
pub mod query_lab_reports;
pub mod query_medications;
pub mod query_vitals;
pub mod search_medical_knowledge;
pub use query_appointments::QueryAppointmentsTool;
pub use query_lab_reports::QueryLabReportsTool;
pub use query_medications::QueryMedicationsTool;
pub use query_vitals::QueryPatientVitalsTool;
pub use search_medical_knowledge::SearchMedicalKnowledgeTool;

View File

@@ -0,0 +1,112 @@
use async_trait::async_trait;
use crate::agent::tool::{AgentTool, ToolContext, ToolResult};
use crate::service::embedding::EmbeddingService;
/// 语义检索医学知识库(参考资料 + 临床指南)
pub struct SearchMedicalKnowledgeTool;
#[async_trait]
impl AgentTool for SearchMedicalKnowledgeTool {
fn name(&self) -> &str {
"search_medical_knowledge"
}
fn description(&self) -> &str {
"在医学知识库中语义检索相关参考资料和临床指南。输入查询文本,返回最相关的知识条目。可选按分析类型过滤(如 trend、lab_report、dialysis_risk"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"required": ["query"],
"properties": {
"query": {
"type": "string",
"description": "搜索查询文本,如'高血压管理指南'或'血红蛋白偏低原因'"
},
"analysis_type": {
"type": "string",
"description": "可选按分析类型过滤trend、lab_report、dialysis_risk 等)"
}
}
})
}
async fn execute(&self, ctx: &ToolContext, params: serde_json::Value) -> ToolResult {
let query_text = match params["query"].as_str() {
Some(q) if !q.trim().is_empty() => q.to_string(),
_ => {
return ToolResult {
output: "请提供搜索查询文本".to_string(),
display_hint: None,
};
}
};
let analysis_type = params["analysis_type"].as_str();
let embedding_svc = EmbeddingService::from_settings(&ctx.db).await;
if !embedding_svc.is_configured() {
return ToolResult {
output: "Embedding API 未配置,无法进行语义搜索".to_string(),
display_hint: None,
};
}
let embedding = match embedding_svc.embed(&query_text).await {
Ok(e) => e,
Err(e) => {
return ToolResult {
output: format!("生成查询向量失败: {}", e),
display_hint: None,
};
}
};
let results = match crate::knowledge::vector_search::KnowledgeSearchRepository::search(
&ctx.db,
ctx.tenant_id,
analysis_type,
&embedding,
5,
0.6,
)
.await
{
Ok(r) => r,
Err(e) => {
return ToolResult {
output: format!("知识库搜索失败: {}", e),
display_hint: None,
};
}
};
if results.is_empty() {
return ToolResult {
output: "未找到相关的医学知识条目".to_string(),
display_hint: None,
};
}
let mut output = String::from("知识库检索结果:\n\n");
for (i, r) in results.iter().enumerate() {
output.push_str(&format!(
"{}. [{}] {}(相似度: {}%\n 来源: {}\n {}\n\n",
i + 1,
r.source_table,
r.title,
(r.similarity * 100.0) as u32,
r.source_name,
r.content.chars().take(200).collect::<String>(),
));
}
ToolResult {
output,
display_hint: None,
}
}
}

View File

@@ -8,6 +8,7 @@ use crate::agent::orchestrator::AgentRunParams;
use crate::agent::sandbox::{get_sandbox_config, resolve_role};
use crate::agent::tool::ToolContext;
use crate::agent::tools::QueryPatientVitalsTool;
use crate::agent::tools::SearchMedicalKnowledgeTool;
use crate::agent::tools::{QueryAppointmentsTool, QueryLabReportsTool, QueryMedicationsTool};
use crate::agent::{AgentOrchestrator, ToolRegistry};
use crate::config_resolver;
@@ -121,6 +122,7 @@ where
registry.register(std::sync::Arc::new(QueryLabReportsTool));
registry.register(std::sync::Arc::new(QueryAppointmentsTool));
registry.register(std::sync::Arc::new(QueryMedicationsTool));
registry.register(std::sync::Arc::new(SearchMedicalKnowledgeTool));
// 根据用户角色获取沙箱配置
let user_role = resolve_role(&ctx.roles);

View File

@@ -0,0 +1,296 @@
use axum::Json;
use axum::extract::{Extension, FromRef, Path, State};
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use serde::Deserialize;
use crate::service::knowledge::{
CreateKnowledgeGuideReq, CreateKnowledgeReferenceReq, ListKnowledgeQuery,
UpdateKnowledgeGuideReq, UpdateKnowledgeReferenceReq,
};
use crate::state::AiState;
// === References ===
#[derive(Debug, Deserialize)]
pub struct ListKnowledgeParams {
pub analysis_type: Option<String>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
#[utoipa::path(
get,
path = "/ai/knowledge/references",
responses((status = 200, description = "参考资料列表")),
tag = "知识库",
security(("bearer_auth" = [])),
)]
pub async fn list_references<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
axum::extract::Query(params): axum::extract::Query<ListKnowledgeParams>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.list")?;
let query = ListKnowledgeQuery {
analysis_type: params.analysis_type,
page: params.page,
page_size: params.page_size,
};
let items = state
.knowledge
.list_references(ctx.tenant_id, &query)
.await?;
Ok(Json(ApiResponse::ok(serde_json::json!({
"data": items,
"total": items.len(),
}))))
}
#[utoipa::path(
post,
path = "/ai/knowledge/references",
responses((status = 200, description = "创建参考资料")),
tag = "知识库",
security(("bearer_auth" = [])),
)]
pub async fn create_reference<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Json(body): Json<CreateKnowledgeReferenceReq>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.manage")?;
if body.title.trim().is_empty() {
return Err(erp_core::error::AppError::Validation("标题不能为空".into()));
}
let id = state
.knowledge
.create_reference(ctx.tenant_id, ctx.user_id, body)
.await?;
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
}
#[utoipa::path(
put,
path = "/ai/knowledge/references/{id}",
responses((status = 200, description = "更新参考资料")),
tag = "知识库",
security(("bearer_auth" = [])),
)]
pub async fn update_reference<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
Json(body): Json<UpdateKnowledgeReferenceReq>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.manage")?;
state
.knowledge
.update_reference(ctx.tenant_id, ctx.user_id, id, body)
.await?;
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
}
#[utoipa::path(
delete,
path = "/ai/knowledge/references/{id}",
responses((status = 200, description = "删除参考资料")),
tag = "知识库",
security(("bearer_auth" = [])),
)]
pub async fn delete_reference<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.manage")?;
state.knowledge.delete_reference(ctx.tenant_id, id).await?;
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
}
#[utoipa::path(
post,
path = "/ai/knowledge/references/{id}/re-embed",
responses((status = 200, description = "重新生成向量")),
tag = "知识库",
security(("bearer_auth" = [])),
)]
pub async fn re_embed_reference<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.manage")?;
state
.knowledge
.re_embed_reference(ctx.tenant_id, id)
.await?;
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
}
// === Guides ===
#[utoipa::path(
get,
path = "/ai/knowledge/guides",
responses((status = 200, description = "临床指南列表")),
tag = "知识库",
security(("bearer_auth" = [])),
)]
pub async fn list_guides<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
axum::extract::Query(params): axum::extract::Query<ListKnowledgeParams>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.list")?;
let query = ListKnowledgeQuery {
analysis_type: params.analysis_type,
page: params.page,
page_size: params.page_size,
};
let items = state.knowledge.list_guides(ctx.tenant_id, &query).await?;
Ok(Json(ApiResponse::ok(serde_json::json!({
"data": items,
"total": items.len(),
}))))
}
#[utoipa::path(
post,
path = "/ai/knowledge/guides",
responses((status = 200, description = "创建临床指南")),
tag = "知识库",
security(("bearer_auth" = [])),
)]
pub async fn create_guide<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Json(body): Json<CreateKnowledgeGuideReq>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.manage")?;
if body.title.trim().is_empty() {
return Err(erp_core::error::AppError::Validation("标题不能为空".into()));
}
let id = state
.knowledge
.create_guide(ctx.tenant_id, ctx.user_id, body)
.await?;
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
}
#[utoipa::path(
put,
path = "/ai/knowledge/guides/{id}",
responses((status = 200, description = "更新临床指南")),
tag = "知识库",
security(("bearer_auth" = [])),
)]
pub async fn update_guide<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
Json(body): Json<UpdateKnowledgeGuideReq>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.manage")?;
state
.knowledge
.update_guide(ctx.tenant_id, ctx.user_id, id, body)
.await?;
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
}
#[utoipa::path(
delete,
path = "/ai/knowledge/guides/{id}",
responses((status = 200, description = "删除临床指南")),
tag = "知识库",
security(("bearer_auth" = [])),
)]
pub async fn delete_guide<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.manage")?;
state.knowledge.delete_guide(ctx.tenant_id, id).await?;
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
}
#[utoipa::path(
post,
path = "/ai/knowledge/guides/{id}/re-embed",
responses((status = 200, description = "重新生成向量")),
tag = "知识库",
security(("bearer_auth" = [])),
)]
pub async fn re_embed_guide<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.knowledge.manage")?;
state.knowledge.re_embed_guide(ctx.tenant_id, id).await?;
Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id }))))
}

View File

@@ -15,6 +15,7 @@ use crate::state::AiState;
pub mod chat_handler;
pub mod config_handler;
pub mod insight_handler;
pub mod knowledge_handler;
pub mod risk_handler;
pub mod rule_handler;
pub mod suggestion_handler;

View File

@@ -1,5 +1,6 @@
pub mod structured_source;
pub mod vector_search;
pub mod vector_source;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};

View File

@@ -0,0 +1,193 @@
//! 向量知识源 — 基于 pgvector 余弦相似度检索,实现 KnowledgeSource trait
use async_trait::async_trait;
use sea_orm::DatabaseConnection;
use std::sync::Arc;
use crate::error::{AiError, AiResult};
use crate::service::embedding::EmbeddingService;
use super::{KnowledgeContext, KnowledgeQuery, KnowledgeSource, Reference};
/// 向量知识源 — 语义检索参考资料和临床指南
pub struct VectorKnowledgeSource {
db: DatabaseConnection,
embedding: Arc<EmbeddingService>,
}
impl VectorKnowledgeSource {
pub fn new(db: DatabaseConnection, embedding: Arc<EmbeddingService>) -> Self {
Self { db, embedding }
}
}
#[async_trait]
impl KnowledgeSource for VectorKnowledgeSource {
async fn get_context(&self, query: &KnowledgeQuery) -> AiResult<KnowledgeContext> {
let query_text = match &query.query_text {
Some(t) if !t.trim().is_empty() => t.clone(),
_ => {
// 无查询文本时回退到基于患者标签的简单拼接
match &query.patient_context {
Some(ctx) if !ctx.tags.is_empty() => ctx.tags.join(" "),
_ => {
return Ok(KnowledgeContext {
source: "vector".into(),
context_text: "无查询文本,跳过向量检索".into(),
references: vec![],
confidence: 0.0,
});
}
}
}
};
if !self.embedding.is_configured() {
return Ok(KnowledgeContext {
source: "vector".into(),
context_text: "Embedding API 未配置,跳过向量检索".into(),
references: vec![],
confidence: 0.0,
});
}
let embedding = match self.embedding.embed(&query_text).await {
Ok(e) => e,
Err(e) => {
tracing::warn!(error = %e, "向量知识源 embedding 失败");
return Ok(KnowledgeContext {
source: "vector".into(),
context_text: "向量生成失败,跳过检索".into(),
references: vec![],
confidence: 0.0,
});
}
};
let results = crate::knowledge::vector_search::KnowledgeSearchRepository::search(
&self.db,
query.tenant_id,
Some(&query.analysis_type),
&embedding,
5,
0.6,
)
.await
.map_err(|e| AiError::KnowledgeError(format!("向量知识检索失败: {}", e)))?;
if results.is_empty() {
return Ok(KnowledgeContext {
source: "vector".into(),
context_text: "向量检索无匹配结果".into(),
references: vec![],
confidence: 0.3,
});
}
let mut context_parts: Vec<String> = Vec::new();
let mut references: Vec<Reference> = Vec::new();
for r in &results {
let content = if r.content.len() > 1500 {
&r.content[..1500]
} else {
&r.content
};
context_parts.push(format!(
"{}{}(来源: {},相似度: {}%\n{}",
r.source_table,
r.title,
r.source_name,
(r.similarity * 100.0) as u32,
content,
));
references.push(Reference {
title: r.title.clone(),
source: r.source_name.clone(),
relevance_score: r.similarity,
});
}
let context_text = {
let full = context_parts.join("\n\n");
if full.len() > 6000 {
full[..6000].to_string()
} else {
full
}
};
let max_similarity = results.iter().map(|r| r.similarity).fold(0.0f32, f32::max);
let confidence = if max_similarity >= 0.9 {
0.95
} else if max_similarity >= 0.8 {
0.85
} else if max_similarity >= 0.7 {
0.75
} else {
0.6
};
Ok(KnowledgeContext {
source: "vector".into(),
context_text,
references,
confidence,
})
}
fn source_type(&self) -> &str {
"vector"
}
async fn health_check(&self) -> AiResult<bool> {
if !self.embedding.is_configured() {
return Ok(false);
}
// 尝试生成一个简单的 embedding 验证 API 可用性
match self.embedding.embed("health check").await {
Ok(_) => Ok(true),
Err(e) => {
tracing::warn!(error = %e, "向量知识源健康检查失败");
Ok(false)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn confidence_tiers() {
assert!((confidence_for(0.95) - 0.95).abs() < 0.01);
assert!((confidence_for(0.85) - 0.85).abs() < 0.01);
assert!((confidence_for(0.75) - 0.75).abs() < 0.01);
assert!((confidence_for(0.65) - 0.6).abs() < 0.01);
}
fn confidence_for(max_similarity: f32) -> f32 {
if max_similarity >= 0.9 {
0.95
} else if max_similarity >= 0.8 {
0.85
} else if max_similarity >= 0.7 {
0.75
} else {
0.6
}
}
#[test]
fn context_truncation_vector() {
let long_context = "x".repeat(10000);
let truncated = if long_context.len() > 6000 {
long_context[..6000].to_string()
} else {
long_context
};
assert_eq!(truncated.len(), 6000);
}
}

View File

@@ -138,6 +138,19 @@ impl ErpModule for AiModule {
description: "查看 AI 客服会话消息历史".into(),
module: "ai".into(),
},
// 知识库权限
PermissionDescriptor {
code: "ai.knowledge.list".into(),
name: "查看知识库".into(),
description: "查看 AI 知识库(参考资料和临床指南)".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.knowledge.manage".into(),
name: "管理知识库".into(),
description: "创建/编辑/删除 AI 知识库条目".into(),
module: "ai".into(),
},
]
}
@@ -502,6 +515,47 @@ impl AiModule {
"/ai/suggestions/{id}/feedback",
axum::routing::post(crate::handler::suggestion_handler::submit_feedback),
)
// 知识库路由
.route(
"/ai/knowledge/references",
axum::routing::get(crate::handler::knowledge_handler::list_references),
)
.route(
"/ai/knowledge/references",
axum::routing::post(crate::handler::knowledge_handler::create_reference),
)
.route(
"/ai/knowledge/references/{id}",
axum::routing::put(crate::handler::knowledge_handler::update_reference),
)
.route(
"/ai/knowledge/references/{id}",
axum::routing::delete(crate::handler::knowledge_handler::delete_reference),
)
.route(
"/ai/knowledge/references/{id}/re-embed",
axum::routing::post(crate::handler::knowledge_handler::re_embed_reference),
)
.route(
"/ai/knowledge/guides",
axum::routing::get(crate::handler::knowledge_handler::list_guides),
)
.route(
"/ai/knowledge/guides",
axum::routing::post(crate::handler::knowledge_handler::create_guide),
)
.route(
"/ai/knowledge/guides/{id}",
axum::routing::put(crate::handler::knowledge_handler::update_guide),
)
.route(
"/ai/knowledge/guides/{id}",
axum::routing::delete(crate::handler::knowledge_handler::delete_guide),
)
.route(
"/ai/knowledge/guides/{id}/re-embed",
axum::routing::post(crate::handler::knowledge_handler::re_embed_guide),
)
.route(
"/ai/dialysis/risk-assessment",
axum::routing::post(crate::handler::assess_dialysis_risk),

View File

@@ -10,7 +10,7 @@ use crate::service::embedding::{EmbeddingService, format_vector};
// ─── DTO ───
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct CreateKnowledgeReferenceReq {
pub title: String,
pub analysis_type: String,
@@ -20,7 +20,7 @@ pub struct CreateKnowledgeReferenceReq {
pub is_enabled: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct UpdateKnowledgeReferenceReq {
pub title: Option<String>,
pub analysis_type: Option<String>,
@@ -30,7 +30,7 @@ pub struct UpdateKnowledgeReferenceReq {
pub is_enabled: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct CreateKnowledgeGuideReq {
pub title: String,
pub analysis_type: String,
@@ -39,7 +39,7 @@ pub struct CreateKnowledgeGuideReq {
pub is_enabled: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
pub struct UpdateKnowledgeGuideReq {
pub title: Option<String>,
pub analysis_type: Option<String>,

View File

@@ -9,6 +9,7 @@ use crate::service::analysis::AnalysisService;
use crate::service::cache::CacheService;
use crate::service::feature_flag_service::FeatureFlagService;
use crate::service::insight_service::InsightService;
use crate::service::knowledge::KnowledgeService;
use crate::service::prompt::PromptService;
use crate::service::quota::QuotaService;
use crate::service::risk_service::RiskService;
@@ -30,4 +31,5 @@ pub struct AiState {
pub risk_service: Arc<RiskService>,
pub insight_service: Arc<InsightService>,
pub feature_flags: Arc<FeatureFlagService>,
pub knowledge: Arc<KnowledgeService>,
}

View File

@@ -155,6 +155,7 @@ mod m20260518_000150_seed_ai_config_permission;
mod m20260518_000151_fix_ai_config_menu_parent;
mod m20260518_000152_seed_ai_provider_permission;
mod m20260518_000153_ai_health_butler_v2;
mod m20260519_000154_seed_ai_knowledge_permissions;
pub struct Migrator;
@@ -317,6 +318,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260518_000151_fix_ai_config_menu_parent::Migration),
Box::new(m20260518_000152_seed_ai_provider_permission::Migration),
Box::new(m20260518_000153_ai_health_butler_v2::Migration),
Box::new(m20260519_000154_seed_ai_knowledge_permissions::Migration),
]
}
}

View File

@@ -0,0 +1,87 @@
//! AI 知识库权限码 seed + 菜单项
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
let sys = "00000000-0000-0000-0000-000000000000";
// 1. Seed 知识库权限码
let perms = [
(
"ai.knowledge.list",
"查看知识库",
"查看 AI 知识库(参考资料和临床指南)",
),
(
"ai.knowledge.manage",
"管理知识库",
"创建/编辑/删除 AI 知识库条目",
),
];
for (code, name, desc) in &perms {
db.execute_unprepared(&format!(
r#"
INSERT INTO permissions (id, tenant_id, code, name, resource, action, description,
created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT gen_random_uuid(), t.id, '{code}', '{name}', 'ai', '{code}', '{desc}',
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
FROM tenant t
WHERE NOT EXISTS (
SELECT 1 FROM permissions p
WHERE p.code = '{code}' AND p.tenant_id = t.id AND p.deleted_at IS NULL
)
"#
)).await?;
// 绑定到管理员角色
db.execute_unprepared(&format!(
r#"
INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope,
created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT r.id, p.id, t.id, 'all',
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
FROM tenant t
JOIN roles r ON r.tenant_id = t.id AND r.code = 'admin' AND r.deleted_at IS NULL
JOIN permissions p ON p.tenant_id = t.id AND p.code = '{code}' AND p.deleted_at IS NULL
WHERE NOT EXISTS (
SELECT 1 FROM role_permissions rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id AND rp.deleted_at IS NULL
)
ON CONFLICT (role_id, permission_id) DO NOTHING
"#
)).await?;
}
// 2. 添加知识库菜单项AI 配置下方)
db.execute_unprepared(&format!(
r#"
INSERT INTO menus (id, tenant_id, parent_id, name, path, icon, sort_order,
permission, menu_type, is_external, status,
created_at, updated_at, created_by, updated_by, deleted_at, version)
SELECT gen_random_uuid(), t.id,
(SELECT m.id FROM menus m WHERE m.path = '/health/ai-config' AND m.tenant_id = t.id AND m.deleted_at IS NULL LIMIT 1),
'AI 知识库', '/health/ai-knowledge', 'BookOutlined', 4,
'ai.knowledge.list', 1, false, 1,
NOW(), NOW(), '{sys}', '{sys}', NULL, 1
FROM tenant t
WHERE NOT EXISTS (
SELECT 1 FROM menus m
WHERE m.path = '/health/ai-knowledge' AND m.tenant_id = t.id AND m.deleted_at IS NULL
)
"#
)).await?;
Ok(())
}
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
Ok(())
}
}

View File

@@ -609,6 +609,12 @@ async fn main() -> anyhow::Result<()> {
feature_flags: std::sync::Arc::new(
erp_ai::service::feature_flag_service::FeatureFlagService::new(db.clone()),
),
knowledge: std::sync::Arc::new(erp_ai::service::knowledge::KnowledgeService::new(
db.clone(),
std::sync::Arc::new(
erp_ai::service::embedding::EmbeddingService::from_settings(&db).await,
),
)),
}
};