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

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