From a999ee0036344631f34b27f182eb1d26bebd6d23 Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 12 May 2026 22:10:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20LLM=20=E8=A1=A5=E5=85=85=E9=A3=8E?= =?UTF-8?q?=E9=99=A9=E5=88=86=E6=9E=90=20+=20=E9=99=8D=E7=BA=A7=E7=AD=96?= =?UTF-8?q?=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scoring.rs 新增 llm_supplement 函数(调用 AI provider 生成补充洞察) - risk_service 新增 compute_risk_with_llm 方法(LLM 失败静默降级) - risk_handler 改用 compute_risk_with_llm --- crates/erp-ai/src/copilot/scoring.rs | 109 ++++++++++++++++++++++ crates/erp-ai/src/handler/risk_handler.rs | 4 +- crates/erp-ai/src/service/risk_service.rs | 38 +++++++- 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/crates/erp-ai/src/copilot/scoring.rs b/crates/erp-ai/src/copilot/scoring.rs index 72ab543..c16c369 100644 --- a/crates/erp-ai/src/copilot/scoring.rs +++ b/crates/erp-ai/src/copilot/scoring.rs @@ -43,3 +43,112 @@ pub fn calculate_risk(matched: Vec) -> RiskScore { matched_rules, } } + +/// LLM 补充分析:基于规则评分结果和患者数据,生成自然语言的补充洞察 +/// 失败时返回 None(降级为纯规则模式) +pub async fn llm_supplement( + provider_registry: &crate::provider::registry::ProviderRegistry, + preferred_provider: &str, + risk_score: &RiskScore, + patient_data: &serde_json::Value, +) -> Option { + let prompt = format!( + "基于以下患者风险评分和匹配规则,是否存在规则未覆盖的风险因素?\ + 风险评分:{}/10,等级:{}\n\ + 匹配规则:{}\n\ + 患者近期数据摘要:{}\n\ + 请给出简洁的补充分析(100字以内),如无补充请回复\"无补充\"。", + risk_score.score, + risk_score.level, + risk_score + .matched_rules + .iter() + .map(|r| format!("- {}(+{}分)", r.name, r.score)) + .collect::>() + .join("\n"), + serde_json::to_string(&serde_json::json!({ + "latest_bp": patient_data.get("vital_signs"), + "latest_lab": patient_data.get("lab_reports"), + })) + .unwrap_or_default(), + ); + let resolved: crate::error::AiResult<_> = provider_registry.resolve(preferred_provider).await; + let resolved = resolved.ok()?; + let req = crate::dto::GenerateRequest { + system_prompt: "你是健康管理AI助手,负责对患者的风险评分进行补充分析。".into(), + user_prompt: prompt, + model: String::new(), + temperature: 0.3, + max_tokens: 256, + }; + let resp: crate::error::AiResult<_> = resolved.provider().generate(req).await; + Some(resp.ok()?.content) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calculate_risk_low() { + let matched = vec![]; + let result = calculate_risk(matched); + assert_eq!(result.score, 0); + assert_eq!(result.level, "low"); + } + + #[test] + fn test_calculate_risk_high() { + let matched = vec![ + ( + uuid::Uuid::now_v7(), + "eGFR下降".into(), + 3, + "warning".into(), + Some("建议调整".into()), + ), + ( + uuid::Uuid::now_v7(), + "血压偏高".into(), + 2, + "warning".into(), + None, + ), + (uuid::Uuid::now_v7(), "失约".into(), 1, "info".into(), None), + ]; + let result = calculate_risk(matched); + assert_eq!(result.score, 6); + assert_eq!(result.level, "high"); + assert_eq!(result.matched_rules.len(), 3); + } + + #[test] + fn test_calculate_risk_clamp_at_10() { + let matched = vec![ + ( + uuid::Uuid::now_v7(), + "危急".into(), + 5, + "critical".into(), + None, + ), + ( + uuid::Uuid::now_v7(), + "严重".into(), + 4, + "critical".into(), + None, + ), + ( + uuid::Uuid::now_v7(), + "异常".into(), + 3, + "warning".into(), + None, + ), + ]; + let result = calculate_risk(matched); + assert_eq!(result.score, 10); + assert_eq!(result.level, "critical"); + } +} diff --git a/crates/erp-ai/src/handler/risk_handler.rs b/crates/erp-ai/src/handler/risk_handler.rs index 880731f..cc4f254 100644 --- a/crates/erp-ai/src/handler/risk_handler.rs +++ b/crates/erp-ai/src/handler/risk_handler.rs @@ -16,10 +16,12 @@ where { require_permission(&ctx, "copilot.risk.view")?; - let risk = crate::service::risk_service::RiskService::compute_risk( + let risk = crate::service::risk_service::RiskService::compute_risk_with_llm( &state.db, ctx.tenant_id, patient_id, + &state.provider_registry, + "ollama", ) .await?; diff --git a/crates/erp-ai/src/service/risk_service.rs b/crates/erp-ai/src/service/risk_service.rs index 7cc68b7..804f3e9 100644 --- a/crates/erp-ai/src/service/risk_service.rs +++ b/crates/erp-ai/src/service/risk_service.rs @@ -3,8 +3,10 @@ use crate::copilot::rules::RuleData; use crate::copilot::scoring::RiskScore; use crate::entity::copilot_risk_snapshots; use crate::entity::copilot_rules; +use crate::provider::registry::ProviderRegistry; use erp_core::error::AppResult; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use std::sync::Arc; use uuid::Uuid; pub struct RiskService; @@ -15,11 +17,44 @@ impl RiskService { db: &sea_orm::DatabaseConnection, tenant_id: Uuid, patient_id: Uuid, + ) -> AppResult { + Self::compute_risk_inner(db, tenant_id, patient_id, None).await + } + + /// 计算风险评分 + LLM 补充分析并 UPSERT 快照 + pub async fn compute_risk_with_llm( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + patient_id: Uuid, + provider_registry: &Arc, + preferred_provider: &str, + ) -> AppResult { + Self::compute_risk_inner( + db, + tenant_id, + patient_id, + Some((provider_registry, preferred_provider)), + ) + .await + } + + async fn compute_risk_inner( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + patient_id: Uuid, + llm_ctx: Option<(&Arc, &str)>, ) -> 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); + // LLM 补充分析(不阻塞,失败静默降级) + let llm_summary = if let Some((registry, provider)) = llm_ctx { + crate::copilot::scoring::llm_supplement(registry, provider, &risk, &patient_data).await + } else { + None + }; + let now = chrono::Utc::now(); let existing = copilot_risk_snapshots::Entity::find() .filter(copilot_risk_snapshots::Column::TenantId.eq(tenant_id)) @@ -37,6 +72,7 @@ impl RiskService { active.risk_score = Set(risk.score); active.risk_level = Set(risk.level.clone()); active.rule_details = Set(rule_details); + active.llm_summary = Set(llm_summary); active.computed_at = Set(now); active.updated_at = Set(now); active.version_lock = Set(active.version_lock.unwrap() + 1); @@ -50,7 +86,7 @@ impl RiskService { risk_score: Set(risk.score), risk_level: Set(risk.level.clone()), rule_details: Set(rule_details), - llm_summary: Set(None), + llm_summary: Set(llm_summary), computed_at: Set(now), data_freshness: Set(None), created_at: Set(now),