diff --git a/crates/erp-ai/src/copilot/engine.rs b/crates/erp-ai/src/copilot/engine.rs new file mode 100644 index 0000000..6c9541e --- /dev/null +++ b/crates/erp-ai/src/copilot/engine.rs @@ -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) + } +} diff --git a/crates/erp-ai/src/copilot/mod.rs b/crates/erp-ai/src/copilot/mod.rs index a4b5b6c..c3f1785 100644 --- a/crates/erp-ai/src/copilot/mod.rs +++ b/crates/erp-ai/src/copilot/mod.rs @@ -1 +1,3 @@ +pub mod engine; pub mod rules; +pub mod scoring; diff --git a/crates/erp-ai/src/copilot/scoring.rs b/crates/erp-ai/src/copilot/scoring.rs new file mode 100644 index 0000000..72ab543 --- /dev/null +++ b/crates/erp-ai/src/copilot/scoring.rs @@ -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, +} + +#[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, +} + +/// 根据匹配规则计算风险评分 +pub fn calculate_risk(matched: Vec) -> 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, + } +} diff --git a/crates/erp-ai/src/dto/copilot.rs b/crates/erp-ai/src/dto/copilot.rs new file mode 100644 index 0000000..f3b26e2 --- /dev/null +++ b/crates/erp-ai/src/dto/copilot.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct ListInsightsQuery { + pub patient_id: Option, + pub insight_type: Option, + pub severity: Option, + pub page: Option, + pub page_size: Option, +} + +#[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, + pub enabled: Option, + pub sort_order: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateRuleBody { + pub name: Option, + pub category: Option, + pub condition_expr: Option, + pub score: Option, + pub severity: Option, + pub suggestion: Option, + pub enabled: Option, + pub sort_order: Option, +} diff --git a/crates/erp-ai/src/dto/mod.rs b/crates/erp-ai/src/dto/mod.rs index 872fba4..72eee09 100644 --- a/crates/erp-ai/src/dto/mod.rs +++ b/crates/erp-ai/src/dto/mod.rs @@ -1,3 +1,4 @@ +pub mod copilot; pub mod suggestion; use serde::{Deserialize, Serialize}; diff --git a/crates/erp-ai/src/handler/insight_handler.rs b/crates/erp-ai/src/handler/insight_handler.rs new file mode 100644 index 0000000..ab0392d --- /dev/null +++ b/crates/erp-ai/src/handler/insight_handler.rs @@ -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( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + 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, +} + +pub async fn dismiss_insight( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + 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}), + ))) +} diff --git a/crates/erp-ai/src/handler/mod.rs b/crates/erp-ai/src/handler/mod.rs index 113b027..1f0653f 100644 --- a/crates/erp-ai/src/handler/mod.rs +++ b/crates/erp-ai/src/handler/mod.rs @@ -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 === diff --git a/crates/erp-ai/src/handler/risk_handler.rs b/crates/erp-ai/src/handler/risk_handler.rs new file mode 100644 index 0000000..880731f --- /dev/null +++ b/crates/erp-ai/src/handler/risk_handler.rs @@ -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( + State(state): State, + Extension(ctx): Extension, + Path(patient_id): Path, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + 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()), + )?))) +} diff --git a/crates/erp-ai/src/handler/rule_handler.rs b/crates/erp-ai/src/handler/rule_handler.rs new file mode 100644 index 0000000..0feb600 --- /dev/null +++ b/crates/erp-ai/src/handler/rule_handler.rs @@ -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( + State(state): State, + Extension(ctx): Extension, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + 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 = system_rules.into_iter().chain(rules).collect(); + Ok(Json(ApiResponse::ok(serde_json::json!({ + "data": all, + "total": all.len(), + })))) +} + +pub async fn create_rule( + State(state): State, + Extension(ctx): Extension, + Json(body): Json, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(rule_id): Path, + Json(body): Json, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + 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))) +} diff --git a/crates/erp-ai/src/module.rs b/crates/erp-ai/src/module.rs index 743ae21..d741efe 100644 --- a/crates/erp-ai/src/module.rs +++ b/crates/erp-ai/src/module.rs @@ -69,6 +69,37 @@ impl ErpModule for AiModule { 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(), + }, ] } @@ -385,5 +416,34 @@ impl AiModule { "/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), + ) } } diff --git a/crates/erp-ai/src/service/insight_service.rs b/crates/erp-ai/src/service/insight_service.rs new file mode 100644 index 0000000..f8a30b3 --- /dev/null +++ b/crates/erp-ai/src/service/insight_service.rs @@ -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, + title: String, + content: serde_json::Value, + rule_matches: Option, + expires_hours: i64, + created_by: Option, + ) -> AppResult { + 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, + insight_type: Option, + severity: Option, + page: u64, + page_size: u64, + ) -> AppResult<(Vec, 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, + ) -> 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 { + 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) + } +} diff --git a/crates/erp-ai/src/service/mod.rs b/crates/erp-ai/src/service/mod.rs index b66ebb8..877aeeb 100644 --- a/crates/erp-ai/src/service/mod.rs +++ b/crates/erp-ai/src/service/mod.rs @@ -5,11 +5,13 @@ pub mod cache; pub mod comparison; pub mod cost; pub mod dialysis_risk_scorer; +pub mod insight_service; pub mod local_rules; pub mod output_parser; pub mod post_process; pub mod prompt; pub mod quota; pub mod reanalysis; +pub mod risk_service; pub mod suggestion; pub mod usage; diff --git a/crates/erp-ai/src/service/risk_service.rs b/crates/erp-ai/src/service/risk_service.rs new file mode 100644 index 0000000..7cc68b7 --- /dev/null +++ b/crates/erp-ai/src/service/risk_service.rs @@ -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 { + 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> { + 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> { + 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 = 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 { + // Phase 0: 返回空数据结构,确保规则引擎不会因缺失数据崩溃 + // 真实数据加载将在 Phase 1 的 "每日批量刷新" 中实现 + let _ = db; + Ok(serde_json::json!({})) + } +} diff --git a/crates/erp-ai/src/state.rs b/crates/erp-ai/src/state.rs index ddbc95f..d5e2bff 100644 --- a/crates/erp-ai/src/state.rs +++ b/crates/erp-ai/src/state.rs @@ -7,8 +7,10 @@ use sea_orm::DatabaseConnection; use crate::provider::registry::ProviderRegistry; use crate::service::analysis::AnalysisService; use crate::service::cache::CacheService; +use crate::service::insight_service::InsightService; use crate::service::prompt::PromptService; use crate::service::quota::QuotaService; +use crate::service::risk_service::RiskService; use crate::service::suggestion::SuggestionService; use crate::service::usage::UsageService; @@ -24,4 +26,6 @@ pub struct AiState { pub provider_registry: Arc, pub quota: Arc, pub cache: Arc, + pub risk_service: Arc, + pub insight_service: Arc, } diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 9fd6380..e41c530 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -617,6 +617,8 @@ async fn main() -> anyhow::Result<()> { provider_registry: registry, quota, 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), } };