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:
iven
2026-05-12 22:10:05 +08:00
parent 44dcfbd5cb
commit a999ee0036
3 changed files with 149 additions and 2 deletions

View File

@@ -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");
}
}

View File

@@ -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?;

View File

@@ -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),