feat(ai): LLM 补充风险分析 + 降级策略
- scoring.rs 新增 llm_supplement 函数(调用 AI provider 生成补充洞察) - risk_service 新增 compute_risk_with_llm 方法(LLM 失败静默降级) - risk_handler 改用 compute_risk_with_llm
This commit is contained in:
@@ -43,3 +43,112 @@ pub fn calculate_risk(matched: Vec<MatchedRuleData>) -> 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<String> {
|
||||
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::<Vec<_>>()
|
||||
.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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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<RiskScore> {
|
||||
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<ProviderRegistry>,
|
||||
preferred_provider: &str,
|
||||
) -> AppResult<RiskScore> {
|
||||
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<ProviderRegistry>, &str)>,
|
||||
) -> 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);
|
||||
|
||||
// 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),
|
||||
|
||||
Reference in New Issue
Block a user