diff --git a/apps/miniprogram/src/pages/health/index.scss b/apps/miniprogram/src/pages/health/index.scss index 0c81d8b..557d053 100644 --- a/apps/miniprogram/src/pages/health/index.scss +++ b/apps/miniprogram/src/pages/health/index.scss @@ -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); +} diff --git a/apps/miniprogram/src/pages/health/index.tsx b/apps/miniprogram/src/pages/health/index.tsx index c6efef9..ad918a1 100644 --- a/apps/miniprogram/src/pages/health/index.tsx +++ b/apps/miniprogram/src/pages/health/index.tsx @@ -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 { @@ -171,16 +172,7 @@ export default function Health() { {aiSuggestions.length > 0 && ( - { - 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' }); - } - }}> + AI 健康建议 {aiSuggestions.length} 条待查看 @@ -192,8 +184,44 @@ export default function Health() { const reason = (params?.reason as string) || (params?.message as string) || typeLabel; return ( - - {reason.slice(0, 40)} + { + if (s.suggestion_type === 'appointment') { + safeNavigateTo(`/pages/appointment/create/index`); + } else if (s.suggestion_type === 'followup') { + safeNavigateTo('/pages/pkg-profile/followups/index'); + } + }}> + + {reason.slice(0, 40)} + + + { + try { + await submitSuggestionFeedback(s.id, 'adopt'); + Taro.showToast({ title: '已采纳', icon: 'success' }); + fetchData(); + } catch { Taro.showToast({ title: '操作失败', icon: 'none' }); } + }}> + 采纳 + + { + try { + await submitSuggestionFeedback(s.id, 'ignore'); + Taro.showToast({ title: '已忽略', icon: 'success' }); + fetchData(); + } catch { Taro.showToast({ title: '操作失败', icon: 'none' }); } + }}> + 忽略 + + { + try { + await submitSuggestionFeedback(s.id, 'consult'); + safeNavigateTo('/pages/consultation/index'); + } catch { Taro.showToast({ title: '操作失败', icon: 'none' }); } + }}> + 咨询医生 + + ); })} diff --git a/apps/miniprogram/src/services/ai-analysis.ts b/apps/miniprogram/src/services/ai-analysis.ts index 4490eb7..d636b3c 100644 --- a/apps/miniprogram/src/services/ai-analysis.ts +++ b/apps/miniprogram/src/services/ai-analysis.ts @@ -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 { diff --git a/crates/erp-ai/src/handler/suggestion_handler.rs b/crates/erp-ai/src/handler/suggestion_handler.rs index f0692b5..0f5bce9 100644 --- a/crates/erp-ai/src/handler/suggestion_handler.rs +++ b/crates/erp-ai/src/handler/suggestion_handler.rs @@ -193,6 +193,69 @@ where } } +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct SubmitFeedbackBody { + pub action: String, // "adopt" | "ignore" | "consult" + pub feedback_text: Option, +} + +/// 患者端提交建议反馈(采纳/忽略/咨询医生) +#[utoipa::path( + post, + path = "/ai/suggestions/{id}/feedback", + responses((status = 200, description = "提交建议反馈")), + tag = "AI 建议", + security(("bearer_auth" = [])), +)] +pub async fn submit_feedback( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(body): Json, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + 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( diff --git a/crates/erp-ai/src/module.rs b/crates/erp-ai/src/module.rs index d0a8794..d6995d4 100644 --- a/crates/erp-ai/src/module.rs +++ b/crates/erp-ai/src/module.rs @@ -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), diff --git a/crates/erp-ai/src/service/mod.rs b/crates/erp-ai/src/service/mod.rs index 4556949..cd1ba33 100644 --- a/crates/erp-ai/src/service/mod.rs +++ b/crates/erp-ai/src/service/mod.rs @@ -15,4 +15,5 @@ pub mod quota; pub mod reanalysis; pub mod risk_service; pub mod suggestion; +pub mod suggestion_feedback; pub mod usage; diff --git a/crates/erp-ai/src/service/risk_service.rs b/crates/erp-ai/src/service/risk_service.rs index ded3281..0650961 100644 --- a/crates/erp-ai/src/service/risk_service.rs +++ b/crates/erp-ai/src/service/risk_service.rs @@ -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 { - // Phase 0: 返回空数据结构,确保规则引擎不会因缺失数据崩溃 - // 真实数据加载将在 Phase 1 的 "每日批量刷新" 中实现 - let _ = db; - Ok(serde_json::json!({})) + use sea_orm::FromQueryResult; + + // 最新一条体征数据(最近 30 天) + #[derive(FromQueryResult)] + struct VitalRow { + systolic_bp_morning: Option, + diastolic_bp_morning: Option, + heart_rate: Option, + blood_sugar: Option, + weight: Option, + spo2: Option, + body_temperature: Option, + } + let vital: Option = 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::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 { + pub async fn refresh_all_patients( + db: &sea_orm::DatabaseConnection, + event_bus: Option<&erp_core::events::EventBus>, + ) -> AppResult { #[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, + "每日扫描洞察创建失败" + ); + } + } + } + } } diff --git a/crates/erp-ai/src/service/suggestion.rs b/crates/erp-ai/src/service/suggestion.rs index 732d1d5..57c248e 100644 --- a/crates/erp-ai/src/service/suggestion.rs +++ b/crates/erp-ai/src/service/suggestion.rs @@ -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 { + 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, diff --git a/crates/erp-ai/src/service/suggestion_feedback.rs b/crates/erp-ai/src/service/suggestion_feedback.rs new file mode 100644 index 0000000..df3bfdf --- /dev/null +++ b/crates/erp-ai/src/service/suggestion_feedback.rs @@ -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, + ) -> AppResult { + 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> { + 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) + } +} diff --git a/crates/erp-message/src/module.rs b/crates/erp-message/src/module.rs index 3e4f726..ef16dcc 100644 --- a/crates/erp-message/src/module.rs +++ b/crates/erp-message/src/module.rs @@ -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::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