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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -15,4 +15,5 @@ pub mod quota;
|
||||
pub mod reanalysis;
|
||||
pub mod risk_service;
|
||||
pub mod suggestion;
|
||||
pub mod suggestion_feedback;
|
||||
pub mod usage;
|
||||
|
||||
@@ -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,
|
||||
"每日扫描洞察创建失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
44
crates/erp-ai/src/service/suggestion_feedback.rs
Normal file
44
crates/erp-ai/src/service/suggestion_feedback.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user