Files
hms/crates/erp-ai/src/module.rs
iven e94f5bc00c feat(ai): 文档管理 handler — CRUD + Multipart 上传
- list_documents: 分页列表(按知识库过滤)
- get_document: 文档详情
- create_manual_document: 手动输入文档
- upload_document: Multipart 文件上传(20MB 限制 + 自动解析)
- delete_document: 软删除(级联减计数)
- 5 条路由注册到 /ai/knowledge-bases/{kb_id}/documents + /ai/documents/*

Phase 2 Task 10
2026-05-27 00:17:43 +08:00

709 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use async_trait::async_trait;
use axum::Router;
use erp_core::module::{ErpModule, ModuleType, PermissionDescriptor};
use std::any::Any;
pub struct AiModule;
#[async_trait]
impl ErpModule for AiModule {
fn name(&self) -> &str {
"ai"
}
fn module_type(&self) -> ModuleType {
ModuleType::Builtin
}
fn dependencies(&self) -> Vec<&str> {
vec!["health"]
}
fn permissions(&self) -> Vec<PermissionDescriptor> {
vec![
PermissionDescriptor {
code: "ai.analysis.list".into(),
name: "查看分析历史".into(),
description: "查看 AI 分析结果历史记录".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.analysis.manage".into(),
name: "请求分析".into(),
description: "发起 AI 分析请求".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.prompt.list".into(),
name: "查看 Prompt".into(),
description: "查看 AI Prompt 模板列表".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.prompt.manage".into(),
name: "管理 Prompt".into(),
description: "创建/编辑/激活/回滚 Prompt 模板".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.usage.list".into(),
name: "查看用量".into(),
description: "查看 AI 用量统计".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.provider.manage".into(),
name: "管理提供商".into(),
description: "管理 AI 提供商配置".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.suggestion.list".into(),
name: "查看 AI 建议".into(),
description: "查看 AI 分析生成的建议列表".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.suggestion.manage".into(),
name: "审批 AI 建议".into(),
description: "批准或拒绝 AI 建议".into(),
module: "ai".into(),
},
// Copilot 权限
PermissionDescriptor {
code: "copilot.insights.list".into(),
name: "查看 Copilot 洞察".into(),
description: "查看 Copilot 生成的患者洞察列表".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "copilot.insights.manage".into(),
name: "管理 Copilot 洞察".into(),
description: "处理/忽略 Copilot 洞察".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "copilot.risk.view".into(),
name: "查看风险评分".into(),
description: "查看 Copilot 计算的患者风险评分".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "copilot.rules.list".into(),
name: "查看 Copilot 规则".into(),
description: "查看 Copilot 规则引擎配置".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "copilot.rules.manage".into(),
name: "管理 Copilot 规则".into(),
description: "创建/编辑/删除 Copilot 规则".into(),
module: "ai".into(),
},
// AI 客服会话权限
PermissionDescriptor {
code: "ai.chat.send".into(),
name: "AI 客服对话".into(),
description: "向 AI 客服发送消息".into(),
module: "ai".into(),
},
// AI 配置管理权限
PermissionDescriptor {
code: "ai.config.read".into(),
name: "查看 AI 配置".into(),
description: "查看 AI 模型和参数配置".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.config.manage".into(),
name: "管理 AI 配置".into(),
description: "修改 AI 模型、温度、Token 等参数配置".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.chat.session.list".into(),
name: "查看 AI 会话列表".into(),
description: "查看用户的 AI 客服会话列表".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.chat.session.manage".into(),
name: "管理 AI 会话".into(),
description: "创建/关闭 AI 客服会话".into(),
module: "ai".into(),
},
PermissionDescriptor {
code: "ai.chat.session.history".into(),
name: "查看 AI 会话历史".into(),
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(),
},
]
}
fn as_any(&self) -> &dyn Any {
self
}
async fn on_startup(
&self,
ctx: &erp_core::module::ModuleContext,
) -> erp_core::error::AppResult<()> {
// 订阅 ai.* 前缀的所有事件reanalysis + analysis.requested
let (mut rx, _handle) = ctx.event_bus.subscribe_filtered("ai.".to_string());
let db = ctx.db.clone();
tokio::spawn(async move {
loop {
match rx.recv().await {
Some(event) if event.event_type == "ai.reanalysis.requested" => {
let suggestion_id = event
.payload
.get("original_suggestion_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let patient_id = event
.payload
.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
match (suggestion_id, patient_id) {
(Some(sid), Some(pid)) => {
if let Err(e) =
crate::service::reanalysis::handle_reanalysis_requested(
&db,
event.tenant_id,
sid,
pid,
)
.await
{
tracing::warn!(
suggestion_id = %sid,
error = %e,
"AI 再分析处理失败"
);
}
}
_ => {
tracing::warn!("ai.reanalysis.requested 事件缺少必要字段");
}
}
}
Some(event) if event.event_type == "ai.analysis.requested" => {
let source_type = event.payload.get("source_type").and_then(|v| v.as_str());
let source_id = event
.payload
.get("source_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let patient_id = event
.payload
.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
tracing::info!(
source_type = ?source_type,
source_id = ?source_id,
patient_id = ?patient_id,
tenant_id = %event.tenant_id,
"收到 AI 分析请求事件"
);
}
// H4: 透析记录→KDIGO 自动风险评估
Some(event) if event.event_type == "ai.dialysis.kdigo_requested" => {
let patient_id = event
.payload
.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let record_id = event
.payload
.get("dialysis_record_id")
.and_then(|v| v.as_str());
tracing::info!(
patient_id = ?patient_id,
record_id = ?record_id,
tenant_id = %event.tenant_id,
"透析→KDIGO 自动评估触发"
);
}
Some(event) => {
tracing::debug!(
event_type = %event.event_type,
"忽略非目标 AI 事件"
);
}
None => {
tracing::info!("AI 事件订阅通道已关闭");
break;
}
}
}
});
// 订阅 erp-health 事件 → 自动入队分析
let (mut health_rx, _) = ctx.event_bus.subscribe_filtered("health_data.".to_string());
let (mut lab_rx, _) = ctx.event_bus.subscribe_filtered("lab_report.".to_string());
let (mut dialysis_rx, _) = ctx.event_bus.subscribe_filtered("dialysis.".to_string());
let queue_db = ctx.db.clone();
tokio::spawn(async move {
let queue = crate::service::analysis_queue::AnalysisQueue::new(queue_db);
loop {
tokio::select! {
event = health_rx.recv() => {
match event {
Some(e) if e.event_type == "health_data.critical_alert" => {
if let Some(pid) = e.payload.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
{
let job = crate::service::analysis_queue::AnalysisJob {
tenant_id: e.tenant_id,
patient_id: pid,
analysis_type: "trend".into(),
priority: 2,
source_event: Some(e.event_type.clone()),
source_ref: "event".into(),
created_by: None,
};
if let Err(err) = queue.enqueue(job).await {
tracing::warn!(error = %err, "健康告警→分析入队失败");
} else {
tracing::info!(tenant = %e.tenant_id, patient = %pid, "健康告警→趋势分析已入队");
}
}
}
Some(e) => {
tracing::debug!(event_type = %e.event_type, "忽略非目标 health_data 事件");
}
None => return,
}
}
event = lab_rx.recv() => {
match event {
Some(e) if e.event_type == "lab_report.uploaded" => {
if let Some(pid) = e.payload.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
{
let job = crate::service::analysis_queue::AnalysisJob {
tenant_id: e.tenant_id,
patient_id: pid,
analysis_type: "lab_report".into(),
priority: 1,
source_event: Some(e.event_type.clone()),
source_ref: "event".into(),
created_by: None,
};
if let Err(err) = queue.enqueue(job).await {
tracing::warn!(error = %err, "化验单上传→分析入队失败");
} else {
tracing::info!(tenant = %e.tenant_id, patient = %pid, "化验单上传→解读分析已入队");
}
}
}
Some(e) => {
tracing::debug!(event_type = %e.event_type, "忽略非目标 lab_report 事件");
}
None => return,
}
}
event = dialysis_rx.recv() => {
match event {
Some(e) if e.event_type == "dialysis.record.created" => {
if let Some(pid) = e.payload.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
{
let job = crate::service::analysis_queue::AnalysisJob {
tenant_id: e.tenant_id,
patient_id: pid,
analysis_type: "dialysis_risk".into(),
priority: 2,
source_event: Some(e.event_type.clone()),
source_ref: "event".into(),
created_by: None,
};
if let Err(err) = queue.enqueue(job).await {
tracing::warn!(error = %err, "透析记录→KDIGO入队失败");
} else {
tracing::info!(tenant = %e.tenant_id, patient = %pid, "透析记录→KDIGO风险评估已入队");
}
}
}
Some(e) => {
tracing::debug!(event_type = %e.event_type, "忽略非目标 dialysis 事件");
}
None => return,
}
}
}
}
});
// Copilot 事件消费者 — 订阅 health 事件触发风险评分刷新
let copilot_handles = crate::event::copilot_consumer::spawn(&ctx.db, &ctx.event_bus);
std::mem::forget(copilot_handles);
// 巡护事件消费者 — 订阅 ai.patrol.requested为未处理告警患者入队趋势分析
let patrol_handle = crate::event::patrol_consumer::spawn(&ctx.db, &ctx.event_bus);
std::mem::forget(patrol_handle);
// 每日凌晨 2:00 批量刷新所有在管患者风险快照
let refresh_db = ctx.db.clone();
let refresh_event_bus = ctx.event_bus.clone();
tokio::spawn(async move {
// 首次执行延迟到下一个凌晨 2:00简单实现延迟 6 小时后开始 24h 周期)
tokio::time::sleep(std::time::Duration::from_secs(6 * 3600)).await;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(86400));
loop {
interval.tick().await;
match crate::service::risk_service::RiskService::refresh_all_patients(
&refresh_db,
Some(&refresh_event_bus),
)
.await
{
Ok(count) => {
tracing::info!(patient_count = count, "每日风险快照刷新完成");
}
Err(e) => {
tracing::warn!(error = %e, "每日风险快照刷新失败");
}
}
// 清理过期洞察 + 过期建议
match crate::service::insight_service::InsightService::cleanup_expired(&refresh_db)
.await
{
Ok(n) => tracing::info!(expired_count = n, "过期洞察清理完成"),
Err(e) => tracing::warn!(error = %e, "过期洞察清理失败"),
}
match crate::service::suggestion::SuggestionService::expire_stale_all_tenants(
&refresh_db,
30,
)
.await
{
Ok(n) => tracing::info!(expired_count = n, "过期建议清理完成"),
Err(e) => tracing::warn!(error = %e, "过期建议清理失败"),
}
}
});
tracing::info!(
module = "ai",
"AI 模块事件处理器已注册(监听 ai.* 事件 + Copilot 事件 + 巡护事件)"
);
Ok(())
}
}
impl AiModule {
pub fn public_routes<S>() -> Router<S>
where
crate::state::AiState: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
}
pub fn protected_routes<S>() -> Router<S>
where
crate::state::AiState: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
.route(
"/ai/chat",
axum::routing::post(crate::handler::chat_handler::chat),
)
.route(
"/ai/chat/sessions",
axum::routing::post(crate::handler::chat_handler::create_session),
)
.route(
"/ai/chat/sessions",
axum::routing::get(crate::handler::chat_handler::list_sessions),
)
.route(
"/ai/chat/sessions/{session_id}/rename",
axum::routing::put(crate::handler::chat_handler::rename_session),
)
.route(
"/ai/chat/sessions/{session_id}/close",
axum::routing::post(crate::handler::chat_handler::close_session),
)
.route(
"/ai/chat/sessions/{session_id}/messages",
axum::routing::get(crate::handler::chat_handler::list_messages),
)
.route(
"/ai/config",
axum::routing::get(crate::handler::config_handler::get_config),
)
.route(
"/ai/config",
axum::routing::put(crate::handler::config_handler::update_config),
)
.route(
"/ai/config/defaults",
axum::routing::get(crate::handler::config_handler::get_config_defaults),
)
.route(
"/ai/analyze/lab-report",
axum::routing::post(crate::handler::stream_lab_report),
)
.route(
"/ai/analyze/trends",
axum::routing::post(crate::handler::stream_trends),
)
.route(
"/ai/analyze/checkup-plan",
axum::routing::post(crate::handler::stream_checkup_plan),
)
.route(
"/ai/analyze/report-summary",
axum::routing::post(crate::handler::stream_report_summary),
)
.route(
"/ai/analyze/follow-up-summary",
axum::routing::post(crate::handler::stream_follow_up_summary),
)
.route(
"/ai/analysis/history",
axum::routing::get(crate::handler::list_analysis),
)
.route(
"/ai/analysis/{id}",
axum::routing::get(crate::handler::get_analysis),
)
.route(
"/ai/prompts",
axum::routing::get(crate::handler::list_prompts),
)
.route(
"/ai/prompts",
axum::routing::post(crate::handler::create_prompt),
)
.route(
"/ai/prompts/{id}/activate",
axum::routing::post(crate::handler::activate_prompt),
)
.route(
"/ai/prompts/{id}/rollback",
axum::routing::post(crate::handler::rollback_prompt),
)
.route(
"/ai/prompts/{id}/deactivate",
axum::routing::post(crate::handler::deactivate_prompt),
)
.route(
"/ai/prompts/{id}",
axum::routing::delete(crate::handler::delete_prompt),
)
.route(
"/ai/usage/overview",
axum::routing::get(crate::handler::usage_overview),
)
.route(
"/ai/usage/by-type",
axum::routing::get(crate::handler::usage_by_type),
)
.route(
"/ai/suggestions",
axum::routing::get(crate::handler::suggestion_handler::list_suggestions),
)
.route(
"/ai/suggestions/{id}/approve",
axum::routing::post(crate::handler::suggestion_handler::approve_suggestion),
)
.route(
"/ai/suggestions/{id}/execute",
axum::routing::post(crate::handler::suggestion_handler::execute_suggestion),
)
.route(
"/ai/suggestions/{id}/comparison",
axum::routing::get(crate::handler::suggestion_handler::get_comparison),
)
.route(
"/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),
)
// 知识库 V2 路由
.route(
"/ai/knowledge-bases",
axum::routing::get(crate::handler::knowledge_v2_handler::list_knowledge_bases),
)
.route(
"/ai/knowledge-bases",
axum::routing::post(crate::handler::knowledge_v2_handler::create_knowledge_base),
)
.route(
"/ai/knowledge-bases/{id}",
axum::routing::get(crate::handler::knowledge_v2_handler::get_knowledge_base),
)
.route(
"/ai/knowledge-bases/{id}",
axum::routing::put(crate::handler::knowledge_v2_handler::update_knowledge_base),
)
.route(
"/ai/knowledge-bases/{id}",
axum::routing::delete(crate::handler::knowledge_v2_handler::delete_knowledge_base),
)
// 文档管理路由
.route(
"/ai/knowledge-bases/{kb_id}/documents",
axum::routing::get(crate::handler::document_handler::list_documents),
)
.route(
"/ai/documents/manual",
axum::routing::post(crate::handler::document_handler::create_manual_document),
)
.route(
"/ai/documents/upload",
axum::routing::post(crate::handler::document_handler::upload_document),
)
.route(
"/ai/documents/{id}",
axum::routing::get(crate::handler::document_handler::get_document),
)
.route(
"/ai/knowledge-bases/{kb_id}/documents/{id}",
axum::routing::delete(crate::handler::document_handler::delete_document),
)
.route(
"/ai/dialysis/risk-assessment",
axum::routing::post(crate::handler::assess_dialysis_risk),
)
.route(
"/ai/providers/health",
axum::routing::get(crate::handler::provider_health),
)
.route(
"/ai/providers",
axum::routing::get(crate::handler::provider_names),
)
.route(
"/ai/quota/summary",
axum::routing::get(crate::handler::quota_summary),
)
.route(
"/ai/health-summary",
axum::routing::get(crate::handler::insight_handler::health_summary),
)
// AI 管理看板
.route(
"/ai/admin/daily-usage",
axum::routing::get(crate::handler::admin_daily_usage),
)
.route(
"/ai/admin/flags",
axum::routing::get(crate::handler::admin_list_flags),
)
.route(
"/ai/admin/flags",
axum::routing::post(crate::handler::admin_update_flag),
)
.route(
"/ai/budget/status",
axum::routing::get(crate::handler::budget_status),
)
.route(
"/ai/cost/estimate",
axum::routing::get(crate::handler::cost_estimate),
)
// Copilot 路由
.route(
"/copilot/insights",
axum::routing::get(crate::handler::insight_handler::list_insights),
)
.route(
"/copilot/insights/{id}",
axum::routing::get(crate::handler::insight_handler::get_insight),
)
.route(
"/copilot/insights/{id}/dismiss",
axum::routing::post(crate::handler::insight_handler::dismiss_insight),
)
.route(
"/copilot/patients/{id}/risk",
axum::routing::get(crate::handler::risk_handler::get_patient_risk),
)
.route(
"/copilot/rules",
axum::routing::get(crate::handler::rule_handler::list_rules),
)
.route(
"/copilot/rules",
axum::routing::post(crate::handler::rule_handler::create_rule),
)
.route(
"/copilot/rules/{id}",
axum::routing::put(crate::handler::rule_handler::update_rule),
)
.route(
"/copilot/rules/{id}",
axum::routing::delete(crate::handler::rule_handler::delete_rule),
)
}
}