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

@@ -289,10 +289,22 @@
}
.ai-suggestion-item {
padding: var(--tk-gap-xs) 0;
border-bottom: 1px solid rgba($acc, 0.15);
&:last-child {
border-bottom: none;
}
}
.ai-suggestion-main {
display: flex;
align-items: center;
gap: var(--tk-gap-xs);
padding: var(--tk-gap-2xs) 0;
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
}
.ai-risk-dot {
@@ -319,3 +331,48 @@
color: $tx2;
line-height: 1.6;
}
/* ─── AI 建议反馈按钮 ─── */
.ai-feedback-row {
display: flex;
gap: var(--tk-gap-xs);
margin-top: var(--tk-gap-2xs);
padding-left: 20px;
}
.ai-feedback-btn {
height: 32px;
border-radius: $r-xs;
@include flex-center;
padding: 0 var(--tk-gap-sm);
&:active {
opacity: var(--tk-touch-feedback-opacity);
}
&.ai-feedback-adopt {
background: rgba($acc, 0.15);
}
&.ai-feedback-ignore {
background: $surface-alt;
}
&.ai-feedback-consult {
background: var(--tk-pri-l);
}
}
.ai-feedback-btn-text {
font-size: var(--tk-font-micro);
font-weight: 500;
color: $tx2;
}
.ai-feedback-adopt .ai-feedback-btn-text {
color: $acc;
}
.ai-feedback-consult .ai-feedback-btn-text {
color: var(--tk-pri);
}

View File

@@ -12,6 +12,7 @@ import SegmentTabs from '../../components/SegmentTabs';
import PageShell from '@/components/ui/PageShell';
import ContentCard from '@/components/ui/ContentCard';
import { useHealthData, VITAL_TABS, type VitalType } from './useHealthData';
import { submitSuggestionFeedback } from '../../services/ai-analysis';
import './index.scss';
function buildRefRange(t: HealthThreshold[]): Record<VitalType, string> {
@@ -171,16 +172,7 @@ export default function Health() {
</View>
{aiSuggestions.length > 0 && (
<View className='ai-suggestion-card' onClick={() => {
const first = aiSuggestions[0];
if (first?.suggestion_type === 'appointment') {
safeNavigateTo(`/pages/appointment/create/index`);
} else if (first?.suggestion_type === 'followup') {
safeNavigateTo('/pages/pkg-profile/followups/index');
} else {
Taro.switchTab({ url: '/pages/health/index' });
}
}}>
<View className='ai-suggestion-card'>
<View className='ai-card-header'>
<Text className='ai-card-title'>AI </Text>
<Text className='ai-card-count'>{aiSuggestions.length} </Text>
@@ -192,8 +184,44 @@ export default function Health() {
const reason = (params?.reason as string) || (params?.message as string) || typeLabel;
return (
<View key={s.id} className='ai-suggestion-item'>
<View className={`ai-risk-dot ${riskCls}`} />
<Text className='ai-suggestion-text'>{reason.slice(0, 40)}</Text>
<View className='ai-suggestion-main' onClick={() => {
if (s.suggestion_type === 'appointment') {
safeNavigateTo(`/pages/appointment/create/index`);
} else if (s.suggestion_type === 'followup') {
safeNavigateTo('/pages/pkg-profile/followups/index');
}
}}>
<View className={`ai-risk-dot ${riskCls}`} />
<Text className='ai-suggestion-text'>{reason.slice(0, 40)}</Text>
</View>
<View className='ai-feedback-row'>
<View className='ai-feedback-btn ai-feedback-adopt' onClick={async () => {
try {
await submitSuggestionFeedback(s.id, 'adopt');
Taro.showToast({ title: '已采纳', icon: 'success' });
fetchData();
} catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
}}>
<Text className='ai-feedback-btn-text'></Text>
</View>
<View className='ai-feedback-btn ai-feedback-ignore' onClick={async () => {
try {
await submitSuggestionFeedback(s.id, 'ignore');
Taro.showToast({ title: '已忽略', icon: 'success' });
fetchData();
} catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
}}>
<Text className='ai-feedback-btn-text'></Text>
</View>
<View className='ai-feedback-btn ai-feedback-consult' onClick={async () => {
try {
await submitSuggestionFeedback(s.id, 'consult');
safeNavigateTo('/pages/consultation/index');
} catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
}}>
<Text className='ai-feedback-btn-text'></Text>
</View>
</View>
</View>
);
})}

View File

@@ -41,6 +41,17 @@ export async function listPendingSuggestions() {
return resp.data || [];
}
export async function submitSuggestionFeedback(
suggestionId: string,
action: 'adopt' | 'ignore' | 'consult',
feedbackText?: string,
) {
return api.post(`/ai/suggestions/${suggestionId}/feedback`, {
action,
feedback_text: feedbackText || null,
});
}
// === 健康摘要 ===
export interface SummaryItem {

View File

@@ -193,6 +193,69 @@ where
}
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct SubmitFeedbackBody {
pub action: String, // "adopt" | "ignore" | "consult"
pub feedback_text: Option<String>,
}
/// 患者端提交建议反馈(采纳/忽略/咨询医生)
#[utoipa::path(
post,
path = "/ai/suggestions/{id}/feedback",
responses((status = 200, description = "提交建议反馈")),
tag = "AI 建议",
security(("bearer_auth" = [])),
)]
pub async fn submit_feedback<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
Json(body): Json<SubmitFeedbackBody>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.suggestion.manage")?;
if !matches!(body.action.as_str(), "adopt" | "ignore" | "consult") {
return Err(erp_core::error::AppError::Validation(
"action 必须为 adopt、ignore 或 consult".into(),
));
}
let feedback_id =
crate::service::suggestion_feedback::SuggestionFeedbackService::submit_feedback(
&state.db,
ctx.tenant_id,
id,
ctx.user_id,
body.action.clone(),
body.feedback_text.clone(),
)
.await?;
// 发布反馈事件
let event = erp_core::events::DomainEvent::new(
"ai.suggestion.feedback",
ctx.tenant_id,
erp_core::events::build_event_payload(serde_json::json!({
"suggestion_id": id,
"action": body.action,
"feedback_text": body.feedback_text,
"user_id": ctx.user_id,
})),
);
state.event_bus.publish(event, &state.db).await;
Ok(Json(ApiResponse::ok(serde_json::json!({
"id": feedback_id,
"suggestion_id": id,
"action": body.action,
}))))
}
/// 发布建议状态变更事件
async fn publish_status_event(state: &AiState, suggestion: &crate::entity::ai_suggestion::Model) {
let event = erp_core::events::DomainEvent::new(

View File

@@ -352,14 +352,18 @@ impl ErpModule for AiModule {
// 每日凌晨 2:00 批量刷新所有在管患者风险快照
let refresh_db = ctx.db.clone();
let refresh_event_bus = ctx.event_bus.clone();
tokio::spawn(async move {
// 首次执行延迟到下一个凌晨 2:00简单实现延迟 6 小时后开始 24h 周期)
tokio::time::sleep(std::time::Duration::from_secs(6 * 3600)).await;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(86400));
loop {
interval.tick().await;
match crate::service::risk_service::RiskService::refresh_all_patients(&refresh_db)
.await
match crate::service::risk_service::RiskService::refresh_all_patients(
&refresh_db,
Some(&refresh_event_bus),
)
.await
{
Ok(count) => {
tracing::info!(patient_count = count, "每日风险快照刷新完成");
@@ -368,6 +372,22 @@ impl ErpModule for AiModule {
tracing::warn!(error = %e, "每日风险快照刷新失败");
}
}
// 清理过期洞察 + 过期建议
match crate::service::insight_service::InsightService::cleanup_expired(&refresh_db)
.await
{
Ok(n) => tracing::info!(expired_count = n, "过期洞察清理完成"),
Err(e) => tracing::warn!(error = %e, "过期洞察清理失败"),
}
match crate::service::suggestion::SuggestionService::expire_stale_all_tenants(
&refresh_db,
30,
)
.await
{
Ok(n) => tracing::info!(expired_count = n, "过期建议清理完成"),
Err(e) => tracing::warn!(error = %e, "过期建议清理失败"),
}
}
});
@@ -478,6 +498,10 @@ impl AiModule {
"/ai/suggestions/{id}/comparison",
axum::routing::get(crate::handler::suggestion_handler::get_comparison),
)
.route(
"/ai/suggestions/{id}/feedback",
axum::routing::post(crate::handler::suggestion_handler::submit_feedback),
)
.route(
"/ai/dialysis/risk-assessment",
axum::routing::post(crate::handler::assess_dialysis_risk),

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)
}
}

View File

@@ -1,6 +1,6 @@
use axum::Router;
use axum::routing::{delete, get, put};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use sea_orm::{ColumnTrait, EntityTrait, FromQueryResult, QueryFilter};
use std::sync::Arc;
use tokio::sync::Semaphore;
use uuid::Uuid;
@@ -1003,6 +1003,69 @@ async fn handle_workflow_event(
"医生在线状态变更"
);
}
// AI Copilot 洞察生成 → 通知主管医生
"copilot.insight.created" => {
let patient_id = event
.payload
.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let severity = event
.payload
.get("severity")
.and_then(|v| v.as_str())
.unwrap_or("warning");
let title = event
.payload
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("AI 健康洞察");
if let Some(pid) = patient_id {
// 查询患者的责任医生(通过 follow_up_task 的 assigned_to
#[derive(sea_orm::FromQueryResult)]
struct DoctorRow {
assigned_to: uuid::Uuid,
}
let doctor: Option<DoctorRow> = DoctorRow::find_by_statement(
sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
"SELECT assigned_to FROM follow_up_task WHERE tenant_id = $1 AND patient_id = $2 AND assigned_to IS NOT NULL AND deleted_at IS NULL AND status IN ('pending', 'in_progress') ORDER BY created_at DESC LIMIT 1",
[event.tenant_id.into(), pid.into()],
),
)
.one(db)
.await
.unwrap_or(None);
if let Some(doc) = doctor {
let priority = match severity {
"critical" => "urgent",
"warning" | "high" => "important",
_ => "normal",
};
if should_skip_for_dnd(event.tenant_id, doc.assigned_to, priority, db).await {
return Ok(());
}
let _ = crate::service::message_service::MessageService::send_system(
event.tenant_id,
doc.assigned_to,
format!("AI 健康洞察:{}", title),
format!(
"AI 系统检测到患者存在「{}」级别的健康风险,请及时关注。洞察内容:{}",
severity, title
),
priority,
Some("ai_insight".to_string()),
Some(event.id),
db,
event_bus,
)
.await
.map_err(|e| e.to_string())?;
}
}
}
// 关怀计划激活 — 温暖通知患者
"care_plan.activated" => {
let patient_id = event