feat(ai): Phase 2B 洞察→推送→反馈闭环 — 风险评分+通知+建议反馈

- 风险评分引擎 load_patient_data 实装(体征+化验异常)
- refresh_all_patients 高风险自动创建洞察+事件推送
- erp-message 订阅 copilot.insight.created 推送医护通知
- 每日 cron 增加洞察过期清理+建议过期清理
- POST /ai/suggestions/{id}/feedback 建议反馈端点
- SuggestionFeedbackService 反馈服务层
- 小程序健康页建议卡片增加采纳/忽略/咨询医生按钮
This commit is contained in:
iven
2026-05-19 01:19:09 +08:00
parent 2660f1afff
commit 9576e80175
10 changed files with 504 additions and 32 deletions

View File

@@ -15,4 +15,5 @@ pub mod quota;
pub mod reanalysis;
pub mod risk_service;
pub mod suggestion;
pub mod suggestion_feedback;
pub mod usage;

View File

@@ -167,22 +167,93 @@ impl RiskService {
}
/// 组装患者数据用于规则评估
/// Phase 0: 基础实现,从 vital_signs_daily 和 lab_report_item 加载最新值
/// Phase 1: 补充聚合字段连续N次偏高等
/// 从 vital_signs_daily 和 lab_report 加载最新值
async fn load_patient_data(
db: &sea_orm::DatabaseConnection,
_tenant_id: Uuid,
_patient_id: Uuid,
tenant_id: Uuid,
patient_id: Uuid,
) -> AppResult<serde_json::Value> {
// Phase 0: 返回空数据结构,确保规则引擎不会因缺失数据崩溃
// 真实数据加载将在 Phase 1 的 "每日批量刷新" 中实现
let _ = db;
Ok(serde_json::json!({}))
use sea_orm::FromQueryResult;
// 最新一条体征数据(最近 30 天)
#[derive(FromQueryResult)]
struct VitalRow {
systolic_bp_morning: Option<i32>,
diastolic_bp_morning: Option<i32>,
heart_rate: Option<i32>,
blood_sugar: Option<f64>,
weight: Option<f64>,
spo2: Option<i32>,
body_temperature: Option<f64>,
}
let vital: Option<VitalRow> = VitalRow::find_by_statement(
sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
"SELECT systolic_bp_morning, diastolic_bp_morning, heart_rate, blood_sugar, weight, spo2, body_temperature FROM vital_signs_daily WHERE tenant_id = $1 AND patient_id = $2 AND deleted_at IS NULL ORDER BY record_date DESC LIMIT 1",
[tenant_id.into(), patient_id.into()],
),
)
.one(db)
.await?;
// 最新化验报告异常计数(最近 90 天)
#[derive(FromQueryResult)]
struct LabAbnormal {
report_type: String,
abnormal_count: i64,
}
let lab_abnormals: Vec<LabAbnormal> = LabAbnormal::find_by_statement(
sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
"SELECT report_type, COUNT(*) as abnormal_count FROM lab_reports WHERE tenant_id = $1 AND patient_id = $2 AND deleted_at IS NULL AND is_abnormal = true AND report_date >= NOW() - INTERVAL '90 days' GROUP BY report_type",
[tenant_id.into(), patient_id.into()],
),
)
.all(db)
.await?;
let mut data = serde_json::Map::new();
if let Some(v) = vital {
if let Some(bp_sys) = v.systolic_bp_morning {
data.insert("systolic_bp_morning".into(), serde_json::json!(bp_sys));
}
if let Some(bp_dia) = v.diastolic_bp_morning {
data.insert("diastolic_bp_morning".into(), serde_json::json!(bp_dia));
}
if let Some(hr) = v.heart_rate {
data.insert("heart_rate".into(), serde_json::json!(hr));
}
if let Some(bs) = v.blood_sugar {
data.insert("blood_sugar".into(), serde_json::json!(bs));
}
if let Some(w) = v.weight {
data.insert("weight".into(), serde_json::json!(w));
}
if let Some(spo2) = v.spo2 {
data.insert("spo2".into(), serde_json::json!(spo2));
}
if let Some(temp) = v.body_temperature {
data.insert("body_temperature".into(), serde_json::json!(temp));
}
}
for lab in lab_abnormals {
data.insert(
format!("lab_abnormal_{}", lab.report_type),
serde_json::json!(lab.abnormal_count),
);
}
Ok(serde_json::Value::Object(data))
}
/// 每日批量刷新所有在管患者的风险快照
/// 通过 raw SQL 查询患者列表(因为 erp-ai 不依赖 erp-health entity
pub async fn refresh_all_patients(db: &sea_orm::DatabaseConnection) -> AppResult<u64> {
pub async fn refresh_all_patients(
db: &sea_orm::DatabaseConnection,
event_bus: Option<&erp_core::events::EventBus>,
) -> AppResult<u64> {
#[derive(sea_orm::FromQueryResult)]
struct PatientRow {
id: Uuid,
@@ -200,15 +271,103 @@ impl RiskService {
let total = patients.len() as u64;
for p in &patients {
if let Err(e) = Self::compute_risk(db, p.tenant_id, p.id).await {
tracing::warn!(
patient_id = %p.id,
tenant_id = %p.tenant_id,
error = %e,
"风险评分刷新失败"
);
match Self::compute_risk(db, p.tenant_id, p.id).await {
Ok(risk) => {
if risk.level == "high" || risk.level == "critical" {
Self::create_risk_insight(db, event_bus, p.tenant_id, p.id, &risk).await;
}
}
Err(e) => {
tracing::warn!(
patient_id = %p.id,
tenant_id = %p.tenant_id,
error = %e,
"风险评分刷新失败"
);
}
}
}
Ok(total)
}
/// 为高风险患者创建风险洞察
async fn create_risk_insight(
db: &sea_orm::DatabaseConnection,
event_bus: Option<&erp_core::events::EventBus>,
tenant_id: Uuid,
patient_id: Uuid,
risk: &RiskScore,
) {
let matched_with_severity: Vec<_> = risk
.matched_rules
.iter()
.map(|r| {
(
r.rule_id,
r.name.clone(),
r.score,
r.severity.clone(),
r.suggestion.clone(),
)
})
.collect();
let insights = crate::copilot::engine::generate_anomaly_insights(
&patient_id.to_string(),
&matched_with_severity,
);
for insight_data in insights {
let severity = insight_data["severity"]
.as_str()
.unwrap_or("warning")
.to_string();
let title = insight_data["title"]
.as_str()
.unwrap_or("风险告警")
.to_string();
let content = insight_data
.get("content")
.cloned()
.unwrap_or(insight_data.clone());
match crate::service::insight_service::InsightService::create_insight(
db,
tenant_id,
patient_id,
"daily_scan".into(),
"risk_refresh".into(),
Some(severity.clone()),
title.clone(),
content,
None,
168,
None,
)
.await
{
Ok(_insight_id) => {
if let Some(bus) = event_bus {
let event = erp_core::events::DomainEvent::new(
"copilot.insight.created",
tenant_id,
erp_core::events::build_event_payload(serde_json::json!({
"patient_id": patient_id.to_string(),
"insight_type": "daily_scan",
"severity": severity,
"title": title,
})),
);
bus.publish(event, db).await;
}
}
Err(e) => {
tracing::warn!(
patient_id = %patient_id,
error = %e,
"每日扫描洞察创建失败"
);
}
}
}
}
}

View File

@@ -169,6 +169,28 @@ impl SuggestionService {
Ok(res.rows_affected())
}
/// 批量清理所有租户的过期建议
pub async fn expire_stale_all_tenants(
db: &sea_orm::DatabaseConnection,
max_age_days: i64,
) -> AppResult<u64> {
let cutoff = chrono::Utc::now() - chrono::Duration::days(max_age_days);
let sql = r#"
UPDATE ai_suggestion
SET status = 'expired', updated_at = NOW(), version_lock = version_lock + 1
WHERE deleted_at IS NULL
AND status IN ('pending', 'approved')
AND created_at < $1
"#;
let result = sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[cutoff.into()],
);
let res = sea_orm::ConnectionTrait::execute(db, result).await?;
Ok(res.rows_affected())
}
/// 标记为解析失败(仅记录日志,不创建建议记录)
pub async fn mark_parse_failed(
_db: &sea_orm::DatabaseConnection,

View File

@@ -0,0 +1,44 @@
use crate::entity::ai_suggestion_feedback;
use erp_core::error::AppResult;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
pub struct SuggestionFeedbackService;
impl SuggestionFeedbackService {
pub async fn submit_feedback(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
suggestion_id: Uuid,
user_id: Uuid,
action: String,
feedback_text: Option<String>,
) -> AppResult<Uuid> {
let id = Uuid::now_v7();
let now = chrono::Utc::now();
let model = ai_suggestion_feedback::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
suggestion_id: Set(suggestion_id),
user_id: Set(user_id),
action: Set(action),
feedback_text: Set(feedback_text),
created_at: Set(now),
};
model.insert(db).await?;
Ok(id)
}
pub async fn list_feedback(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
suggestion_id: Uuid,
) -> AppResult<Vec<ai_suggestion_feedback::Model>> {
let items = ai_suggestion_feedback::Entity::find()
.filter(ai_suggestion_feedback::Column::TenantId.eq(tenant_id))
.filter(ai_suggestion_feedback::Column::SuggestionId.eq(suggestion_id))
.all(db)
.await?;
Ok(items)
}
}