feat(ai): Copilot 评分引擎 + Handler + 路由 + 权限码
- scoring.rs: 混合评分 (calculate_risk) + RiskScore/MatchedRule 结构 - engine.rs: CopilotEngine 协调规则评估和评分 - risk_service.rs: 风险计算 + UPSERT 快照 + 规则加载 - insight_service.rs: 洞察 CRUD + 过期清理 - 3 个 Handler: insight/risk/rule,7 个 API 端点 - 5 个权限码: copilot.insights.list/manage, copilot.risk.view, copilot.rules.list/manage - AiState 扩展 risk_service + insight_service
This commit is contained in:
14
crates/erp-ai/src/copilot/engine.rs
Normal file
14
crates/erp-ai/src/copilot/engine.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use crate::copilot::rules::{RuleData, evaluate_rules};
|
||||||
|
use crate::copilot::scoring::{RiskScore, calculate_risk};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// Copilot 引擎:协调规则评估和评分
|
||||||
|
pub struct CopilotEngine;
|
||||||
|
|
||||||
|
impl CopilotEngine {
|
||||||
|
/// 对患者数据运行所有规则并生成风险评分
|
||||||
|
pub fn assess_patient(rules: &[RuleData], patient_data: &Value) -> RiskScore {
|
||||||
|
let matched = evaluate_rules(rules, patient_data);
|
||||||
|
calculate_risk(matched)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,3 @@
|
|||||||
|
pub mod engine;
|
||||||
pub mod rules;
|
pub mod rules;
|
||||||
|
pub mod scoring;
|
||||||
|
|||||||
45
crates/erp-ai/src/copilot/scoring.rs
Normal file
45
crates/erp-ai/src/copilot/scoring.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
use crate::copilot::rules::MatchedRuleData;
|
||||||
|
|
||||||
|
/// 风险评分结果
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct RiskScore {
|
||||||
|
pub score: i16,
|
||||||
|
pub level: String,
|
||||||
|
pub matched_rules: Vec<MatchedRule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct MatchedRule {
|
||||||
|
pub rule_id: uuid::Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub score: i16,
|
||||||
|
pub severity: String,
|
||||||
|
pub suggestion: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据匹配规则计算风险评分
|
||||||
|
pub fn calculate_risk(matched: Vec<MatchedRuleData>) -> RiskScore {
|
||||||
|
let total: i16 = matched.iter().map(|(_, _, s, _, _)| *s).sum();
|
||||||
|
let clamped = total.clamp(0, 10);
|
||||||
|
let level = match clamped {
|
||||||
|
0..=2 => "low".to_string(),
|
||||||
|
3..=5 => "medium".to_string(),
|
||||||
|
6..=8 => "high".to_string(),
|
||||||
|
_ => "critical".to_string(),
|
||||||
|
};
|
||||||
|
let matched_rules = matched
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, name, score, severity, suggestion)| MatchedRule {
|
||||||
|
rule_id: id,
|
||||||
|
name,
|
||||||
|
score,
|
||||||
|
severity,
|
||||||
|
suggestion,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
RiskScore {
|
||||||
|
score: clamped,
|
||||||
|
level,
|
||||||
|
matched_rules,
|
||||||
|
}
|
||||||
|
}
|
||||||
52
crates/erp-ai/src/dto/copilot.rs
Normal file
52
crates/erp-ai/src/dto/copilot.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ListInsightsQuery {
|
||||||
|
pub patient_id: Option<uuid::Uuid>,
|
||||||
|
pub insight_type: Option<String>,
|
||||||
|
pub severity: Option<String>,
|
||||||
|
pub page: Option<u64>,
|
||||||
|
pub page_size: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum InsightType {
|
||||||
|
RiskScore,
|
||||||
|
Anomaly,
|
||||||
|
FollowUpHint,
|
||||||
|
ConsultHint,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RiskLevel {
|
||||||
|
Low,
|
||||||
|
Medium,
|
||||||
|
High,
|
||||||
|
Critical,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateRuleBody {
|
||||||
|
pub name: String,
|
||||||
|
pub category: String,
|
||||||
|
pub condition_expr: serde_json::Value,
|
||||||
|
pub score: i16,
|
||||||
|
pub severity: String,
|
||||||
|
pub suggestion: Option<String>,
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateRuleBody {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub condition_expr: Option<serde_json::Value>,
|
||||||
|
pub score: Option<i16>,
|
||||||
|
pub severity: Option<String>,
|
||||||
|
pub suggestion: Option<String>,
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod copilot;
|
||||||
pub mod suggestion;
|
pub mod suggestion;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|||||||
93
crates/erp-ai/src/handler/insight_handler.rs
Normal file
93
crates/erp-ai/src/handler/insight_handler.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Extension, FromRef, Path, Query, State};
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, TenantContext};
|
||||||
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::dto::copilot::ListInsightsQuery;
|
||||||
|
use crate::state::AiState;
|
||||||
|
|
||||||
|
pub async fn list_insights<S>(
|
||||||
|
State(state): State<AiState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Query(params): Query<ListInsightsQuery>,
|
||||||
|
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||||
|
where
|
||||||
|
AiState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "copilot.insights.list")?;
|
||||||
|
|
||||||
|
let page = params.page.unwrap_or(1);
|
||||||
|
let page_size = params.page_size.unwrap_or(20);
|
||||||
|
|
||||||
|
let (items, total) = crate::service::insight_service::InsightService::list_insights(
|
||||||
|
&state.db,
|
||||||
|
ctx.tenant_id,
|
||||||
|
params.patient_id,
|
||||||
|
params.insight_type,
|
||||||
|
params.severity,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||||
|
"data": items,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_insight<S>(
|
||||||
|
State(state): State<AiState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<uuid::Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<crate::entity::copilot_insights::Model>>, erp_core::error::AppError>
|
||||||
|
where
|
||||||
|
AiState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "copilot.insights.list")?;
|
||||||
|
|
||||||
|
let insight = crate::entity::copilot_insights::Entity::find()
|
||||||
|
.filter(crate::entity::copilot_insights::Column::Id.eq(id))
|
||||||
|
.filter(crate::entity::copilot_insights::Column::TenantId.eq(ctx.tenant_id))
|
||||||
|
.filter(crate::entity::copilot_insights::Column::DeletedAt.is_null())
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| erp_core::error::AppError::NotFound("洞察记录".into()))?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(insight)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DismissBody {
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn dismiss_insight<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, "copilot.insights.manage")?;
|
||||||
|
|
||||||
|
crate::service::insight_service::InsightService::dismiss_insight(
|
||||||
|
&state.db,
|
||||||
|
ctx.tenant_id,
|
||||||
|
id,
|
||||||
|
Some(ctx.user_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(
|
||||||
|
serde_json::json!({"dismissed": true}),
|
||||||
|
)))
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ use std::convert::Infallible;
|
|||||||
use crate::dto::{AnalysisSseEvent, AnalysisType};
|
use crate::dto::{AnalysisSseEvent, AnalysisType};
|
||||||
use crate::state::AiState;
|
use crate::state::AiState;
|
||||||
|
|
||||||
|
pub mod insight_handler;
|
||||||
|
pub mod risk_handler;
|
||||||
|
pub mod rule_handler;
|
||||||
pub mod suggestion_handler;
|
pub mod suggestion_handler;
|
||||||
|
|
||||||
// === 分析请求 Body ===
|
// === 分析请求 Body ===
|
||||||
|
|||||||
29
crates/erp-ai/src/handler/risk_handler.rs
Normal file
29
crates/erp-ai/src/handler/risk_handler.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Extension, FromRef, Path, State};
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, TenantContext};
|
||||||
|
|
||||||
|
use crate::state::AiState;
|
||||||
|
|
||||||
|
pub async fn get_patient_risk<S>(
|
||||||
|
State(state): State<AiState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(patient_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, "copilot.risk.view")?;
|
||||||
|
|
||||||
|
let risk = crate::service::risk_service::RiskService::compute_risk(
|
||||||
|
&state.db,
|
||||||
|
ctx.tenant_id,
|
||||||
|
patient_id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(serde_json::to_value(&risk).map_err(
|
||||||
|
|e| erp_core::error::AppError::Internal(e.to_string()),
|
||||||
|
)?)))
|
||||||
|
}
|
||||||
129
crates/erp-ai/src/handler/rule_handler.rs
Normal file
129
crates/erp-ai/src/handler/rule_handler.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Extension, FromRef, Path, State};
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, TenantContext};
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
|
||||||
|
|
||||||
|
use crate::dto::copilot::{CreateRuleBody, UpdateRuleBody};
|
||||||
|
use crate::entity::copilot_rules;
|
||||||
|
use crate::state::AiState;
|
||||||
|
|
||||||
|
pub async fn list_rules<S>(
|
||||||
|
State(state): State<AiState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||||
|
where
|
||||||
|
AiState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "copilot.rules.list")?;
|
||||||
|
|
||||||
|
let rules = copilot_rules::Entity::find()
|
||||||
|
.filter(copilot_rules::Column::TenantId.eq(ctx.tenant_id))
|
||||||
|
.filter(copilot_rules::Column::DeletedAt.is_null())
|
||||||
|
.order_by_asc(copilot_rules::Column::SortOrder)
|
||||||
|
.all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 同时包含系统级规则
|
||||||
|
let system_rules = copilot_rules::Entity::find()
|
||||||
|
.filter(copilot_rules::Column::TenantId.eq(uuid::Uuid::nil()))
|
||||||
|
.filter(copilot_rules::Column::DeletedAt.is_null())
|
||||||
|
.order_by_asc(copilot_rules::Column::SortOrder)
|
||||||
|
.all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let all: Vec<copilot_rules::Model> = system_rules.into_iter().chain(rules).collect();
|
||||||
|
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||||
|
"data": all,
|
||||||
|
"total": all.len(),
|
||||||
|
}))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_rule<S>(
|
||||||
|
State(state): State<AiState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Json(body): Json<CreateRuleBody>,
|
||||||
|
) -> Result<Json<ApiResponse<copilot_rules::Model>>, erp_core::error::AppError>
|
||||||
|
where
|
||||||
|
AiState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "copilot.rules.manage")?;
|
||||||
|
|
||||||
|
let id = uuid::Uuid::now_v7();
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let model = copilot_rules::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
tenant_id: Set(ctx.tenant_id),
|
||||||
|
name: Set(body.name),
|
||||||
|
category: Set(body.category),
|
||||||
|
condition_expr: Set(body.condition_expr),
|
||||||
|
score: Set(body.score),
|
||||||
|
severity: Set(body.severity),
|
||||||
|
suggestion: Set(body.suggestion),
|
||||||
|
enabled: Set(body.enabled.unwrap_or(true)),
|
||||||
|
sort_order: Set(body.sort_order.unwrap_or(0)),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(Some(ctx.user_id)),
|
||||||
|
updated_by: Set(Some(ctx.user_id)),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version_lock: Set(1),
|
||||||
|
};
|
||||||
|
let result = model.insert(&state.db).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_rule<S>(
|
||||||
|
State(state): State<AiState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(rule_id): Path<uuid::Uuid>,
|
||||||
|
Json(body): Json<UpdateRuleBody>,
|
||||||
|
) -> Result<Json<ApiResponse<copilot_rules::Model>>, erp_core::error::AppError>
|
||||||
|
where
|
||||||
|
AiState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "copilot.rules.manage")?;
|
||||||
|
|
||||||
|
let model = copilot_rules::Entity::find()
|
||||||
|
.filter(copilot_rules::Column::Id.eq(rule_id))
|
||||||
|
.filter(copilot_rules::Column::TenantId.eq(ctx.tenant_id))
|
||||||
|
.filter(copilot_rules::Column::DeletedAt.is_null())
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| erp_core::error::AppError::NotFound("Copilot 规则".into()))?;
|
||||||
|
|
||||||
|
let mut active: copilot_rules::ActiveModel = model.into();
|
||||||
|
if let Some(name) = body.name {
|
||||||
|
active.name = Set(name);
|
||||||
|
}
|
||||||
|
if let Some(category) = body.category {
|
||||||
|
active.category = Set(category);
|
||||||
|
}
|
||||||
|
if let Some(condition_expr) = body.condition_expr {
|
||||||
|
active.condition_expr = Set(condition_expr);
|
||||||
|
}
|
||||||
|
if let Some(score) = body.score {
|
||||||
|
active.score = Set(score);
|
||||||
|
}
|
||||||
|
if let Some(severity) = body.severity {
|
||||||
|
active.severity = Set(severity);
|
||||||
|
}
|
||||||
|
if let Some(suggestion) = body.suggestion {
|
||||||
|
active.suggestion = Set(Some(suggestion));
|
||||||
|
}
|
||||||
|
if let Some(enabled) = body.enabled {
|
||||||
|
active.enabled = Set(enabled);
|
||||||
|
}
|
||||||
|
if let Some(sort_order) = body.sort_order {
|
||||||
|
active.sort_order = Set(sort_order);
|
||||||
|
}
|
||||||
|
active.updated_at = Set(chrono::Utc::now());
|
||||||
|
active.updated_by = Set(Some(ctx.user_id));
|
||||||
|
active.version_lock = Set(active.version_lock.unwrap() + 1);
|
||||||
|
|
||||||
|
let result = active.update(&state.db).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
@@ -69,6 +69,37 @@ impl ErpModule for AiModule {
|
|||||||
description: "批准或拒绝 AI 建议".into(),
|
description: "批准或拒绝 AI 建议".into(),
|
||||||
module: "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(),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,5 +416,34 @@ impl AiModule {
|
|||||||
"/ai/cost/estimate",
|
"/ai/cost/estimate",
|
||||||
axum::routing::get(crate::handler::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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
130
crates/erp-ai/src/service/insight_service.rs
Normal file
130
crates/erp-ai/src/service/insight_service.rs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
use crate::entity::copilot_insights;
|
||||||
|
use erp_core::error::AppResult;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub struct InsightService;
|
||||||
|
|
||||||
|
impl InsightService {
|
||||||
|
/// 创建洞察记录
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn create_insight(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
patient_id: Uuid,
|
||||||
|
insight_type: String,
|
||||||
|
source: String,
|
||||||
|
severity: Option<String>,
|
||||||
|
title: String,
|
||||||
|
content: serde_json::Value,
|
||||||
|
rule_matches: Option<serde_json::Value>,
|
||||||
|
expires_hours: i64,
|
||||||
|
created_by: Option<Uuid>,
|
||||||
|
) -> AppResult<Uuid> {
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let model = copilot_insights::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
patient_id: Set(patient_id),
|
||||||
|
insight_type: Set(insight_type),
|
||||||
|
source: Set(source),
|
||||||
|
severity: Set(severity),
|
||||||
|
title: Set(title),
|
||||||
|
content: Set(content),
|
||||||
|
rule_matches: Set(rule_matches),
|
||||||
|
llm_supplement: Set(None),
|
||||||
|
expires_at: Set(now + chrono::Duration::hours(expires_hours)),
|
||||||
|
is_read: Set(false),
|
||||||
|
is_dismissed: Set(false),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(created_by),
|
||||||
|
updated_by: Set(created_by),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version_lock: Set(1),
|
||||||
|
};
|
||||||
|
model.insert(db).await?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分页查询洞察列表
|
||||||
|
pub async fn list_insights(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
patient_id: Option<Uuid>,
|
||||||
|
insight_type: Option<String>,
|
||||||
|
severity: Option<String>,
|
||||||
|
page: u64,
|
||||||
|
page_size: u64,
|
||||||
|
) -> AppResult<(Vec<copilot_insights::Model>, u64)> {
|
||||||
|
let mut query = copilot_insights::Entity::find()
|
||||||
|
.filter(copilot_insights::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(copilot_insights::Column::DeletedAt.is_null())
|
||||||
|
.filter(copilot_insights::Column::IsDismissed.eq(false));
|
||||||
|
|
||||||
|
if let Some(pid) = patient_id {
|
||||||
|
query = query.filter(copilot_insights::Column::PatientId.eq(pid));
|
||||||
|
}
|
||||||
|
if let Some(ref t) = insight_type {
|
||||||
|
query = query.filter(copilot_insights::Column::InsightType.eq(t.as_str()));
|
||||||
|
}
|
||||||
|
if let Some(ref s) = severity {
|
||||||
|
query = query.filter(copilot_insights::Column::Severity.eq(s.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = query.clone().count(db).await?;
|
||||||
|
let items = query
|
||||||
|
.order_by_desc(copilot_insights::Column::CreatedAt)
|
||||||
|
.paginate(db, page_size)
|
||||||
|
.fetch_page(page.saturating_sub(1))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((items, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记洞察已处理
|
||||||
|
pub async fn dismiss_insight(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
insight_id: Uuid,
|
||||||
|
updated_by: Option<Uuid>,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let model = copilot_insights::Entity::find()
|
||||||
|
.filter(copilot_insights::Column::Id.eq(insight_id))
|
||||||
|
.filter(copilot_insights::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(copilot_insights::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| erp_core::error::AppError::NotFound("洞察记录".into()))?;
|
||||||
|
|
||||||
|
let mut active: copilot_insights::ActiveModel = model.into();
|
||||||
|
active.is_dismissed = Set(true);
|
||||||
|
active.updated_at = Set(chrono::Utc::now());
|
||||||
|
active.updated_by = Set(updated_by);
|
||||||
|
active.version_lock = Set(active.version_lock.unwrap() + 1);
|
||||||
|
active.update(db).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理过期洞察
|
||||||
|
pub async fn cleanup_expired(db: &sea_orm::DatabaseConnection) -> AppResult<u64> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let expired = copilot_insights::Entity::find()
|
||||||
|
.filter(copilot_insights::Column::ExpiresAt.lte(now))
|
||||||
|
.filter(copilot_insights::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let count = expired.len() as u64;
|
||||||
|
for model in expired {
|
||||||
|
let mut active: copilot_insights::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(now));
|
||||||
|
active.updated_at = Set(now);
|
||||||
|
active.update(db).await?;
|
||||||
|
}
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,13 @@ pub mod cache;
|
|||||||
pub mod comparison;
|
pub mod comparison;
|
||||||
pub mod cost;
|
pub mod cost;
|
||||||
pub mod dialysis_risk_scorer;
|
pub mod dialysis_risk_scorer;
|
||||||
|
pub mod insight_service;
|
||||||
pub mod local_rules;
|
pub mod local_rules;
|
||||||
pub mod output_parser;
|
pub mod output_parser;
|
||||||
pub mod post_process;
|
pub mod post_process;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
pub mod quota;
|
pub mod quota;
|
||||||
pub mod reanalysis;
|
pub mod reanalysis;
|
||||||
|
pub mod risk_service;
|
||||||
pub mod suggestion;
|
pub mod suggestion;
|
||||||
pub mod usage;
|
pub mod usage;
|
||||||
|
|||||||
132
crates/erp-ai/src/service/risk_service.rs
Normal file
132
crates/erp-ai/src/service/risk_service.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
use crate::copilot::engine::CopilotEngine;
|
||||||
|
use crate::copilot::rules::RuleData;
|
||||||
|
use crate::copilot::scoring::RiskScore;
|
||||||
|
use crate::entity::copilot_risk_snapshots;
|
||||||
|
use crate::entity::copilot_rules;
|
||||||
|
use erp_core::error::AppResult;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub struct RiskService;
|
||||||
|
|
||||||
|
impl RiskService {
|
||||||
|
/// 计算患者风险评分并 UPSERT 快照
|
||||||
|
pub async fn compute_risk(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
patient_id: Uuid,
|
||||||
|
) -> AppResult<RiskScore> {
|
||||||
|
let rules = Self::load_rules(db, tenant_id).await?;
|
||||||
|
let patient_data = Self::load_patient_data(db, tenant_id, patient_id).await?;
|
||||||
|
let risk = CopilotEngine::assess_patient(&rules, &patient_data);
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let existing = copilot_risk_snapshots::Entity::find()
|
||||||
|
.filter(copilot_risk_snapshots::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(copilot_risk_snapshots::Column::PatientId.eq(patient_id))
|
||||||
|
.filter(copilot_risk_snapshots::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let rule_details = serde_json::json!({
|
||||||
|
"matched_rules": risk.matched_rules,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(model) = existing {
|
||||||
|
let mut active: copilot_risk_snapshots::ActiveModel = model.into();
|
||||||
|
active.risk_score = Set(risk.score);
|
||||||
|
active.risk_level = Set(risk.level.clone());
|
||||||
|
active.rule_details = Set(rule_details);
|
||||||
|
active.computed_at = Set(now);
|
||||||
|
active.updated_at = Set(now);
|
||||||
|
active.version_lock = Set(active.version_lock.unwrap() + 1);
|
||||||
|
active.update(db).await?;
|
||||||
|
} else {
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let model = copilot_risk_snapshots::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
patient_id: Set(patient_id),
|
||||||
|
risk_score: Set(risk.score),
|
||||||
|
risk_level: Set(risk.level.clone()),
|
||||||
|
rule_details: Set(rule_details),
|
||||||
|
llm_summary: Set(None),
|
||||||
|
computed_at: Set(now),
|
||||||
|
data_freshness: Set(None),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(None),
|
||||||
|
updated_by: Set(None),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version_lock: Set(1),
|
||||||
|
};
|
||||||
|
model.insert(db).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(risk)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询患者最新风险快照
|
||||||
|
pub async fn get_latest_risk(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
patient_id: Uuid,
|
||||||
|
) -> AppResult<Option<copilot_risk_snapshots::Model>> {
|
||||||
|
let snapshot = copilot_risk_snapshots::Entity::find()
|
||||||
|
.filter(copilot_risk_snapshots::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(copilot_risk_snapshots::Column::PatientId.eq(patient_id))
|
||||||
|
.filter(copilot_risk_snapshots::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await?;
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载租户的启用规则(含系统级规则)
|
||||||
|
async fn load_rules(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
) -> AppResult<Vec<RuleData>> {
|
||||||
|
let rules = copilot_rules::Entity::find()
|
||||||
|
.filter(copilot_rules::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(copilot_rules::Column::Enabled.eq(true))
|
||||||
|
.filter(copilot_rules::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let system_rules = copilot_rules::Entity::find()
|
||||||
|
.filter(copilot_rules::Column::TenantId.eq(Uuid::nil()))
|
||||||
|
.filter(copilot_rules::Column::Enabled.eq(true))
|
||||||
|
.filter(copilot_rules::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let all_rules: Vec<copilot_rules::Model> = rules.into_iter().chain(system_rules).collect();
|
||||||
|
Ok(all_rules
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
(
|
||||||
|
r.id,
|
||||||
|
r.name,
|
||||||
|
r.condition_expr,
|
||||||
|
r.score,
|
||||||
|
r.severity,
|
||||||
|
r.suggestion,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 组装患者数据用于规则评估
|
||||||
|
/// Phase 0: 基础实现,从 vital_signs_daily 和 lab_report_item 加载最新值
|
||||||
|
/// Phase 1: 补充聚合字段(连续N次偏高等)
|
||||||
|
async fn load_patient_data(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
_tenant_id: Uuid,
|
||||||
|
_patient_id: Uuid,
|
||||||
|
) -> AppResult<serde_json::Value> {
|
||||||
|
// Phase 0: 返回空数据结构,确保规则引擎不会因缺失数据崩溃
|
||||||
|
// 真实数据加载将在 Phase 1 的 "每日批量刷新" 中实现
|
||||||
|
let _ = db;
|
||||||
|
Ok(serde_json::json!({}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,10 @@ use sea_orm::DatabaseConnection;
|
|||||||
use crate::provider::registry::ProviderRegistry;
|
use crate::provider::registry::ProviderRegistry;
|
||||||
use crate::service::analysis::AnalysisService;
|
use crate::service::analysis::AnalysisService;
|
||||||
use crate::service::cache::CacheService;
|
use crate::service::cache::CacheService;
|
||||||
|
use crate::service::insight_service::InsightService;
|
||||||
use crate::service::prompt::PromptService;
|
use crate::service::prompt::PromptService;
|
||||||
use crate::service::quota::QuotaService;
|
use crate::service::quota::QuotaService;
|
||||||
|
use crate::service::risk_service::RiskService;
|
||||||
use crate::service::suggestion::SuggestionService;
|
use crate::service::suggestion::SuggestionService;
|
||||||
use crate::service::usage::UsageService;
|
use crate::service::usage::UsageService;
|
||||||
|
|
||||||
@@ -24,4 +26,6 @@ pub struct AiState {
|
|||||||
pub provider_registry: Arc<ProviderRegistry>,
|
pub provider_registry: Arc<ProviderRegistry>,
|
||||||
pub quota: Arc<QuotaService>,
|
pub quota: Arc<QuotaService>,
|
||||||
pub cache: Arc<CacheService>,
|
pub cache: Arc<CacheService>,
|
||||||
|
pub risk_service: Arc<RiskService>,
|
||||||
|
pub insight_service: Arc<InsightService>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -617,6 +617,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
provider_registry: registry,
|
provider_registry: registry,
|
||||||
quota,
|
quota,
|
||||||
cache,
|
cache,
|
||||||
|
risk_service: std::sync::Arc::new(erp_ai::service::risk_service::RiskService),
|
||||||
|
insight_service: std::sync::Arc::new(erp_ai::service::insight_service::InsightService),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user