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:
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::state::AiState;
|
||||
|
||||
pub mod insight_handler;
|
||||
pub mod risk_handler;
|
||||
pub mod rule_handler;
|
||||
pub mod suggestion_handler;
|
||||
|
||||
// === 分析请求 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)))
|
||||
}
|
||||
Reference in New Issue
Block a user