Compare commits
5 Commits
0a4825be99
...
3cba699ca0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cba699ca0 | ||
|
|
8b837c0591 | ||
|
|
598c06885f | ||
|
|
92c1c3c17d | ||
|
|
5d2402a1e7 |
@@ -300,3 +300,50 @@
|
||||
color: $tx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── AI 建议卡片 ─── */
|
||||
.ai-suggestion-card {
|
||||
background: $card;
|
||||
border-radius: $r;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: $shadow-sm;
|
||||
border-left: 4px solid $pri;
|
||||
}
|
||||
|
||||
.ai-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ai-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $tx;
|
||||
}
|
||||
|
||||
.ai-card-count {
|
||||
font-size: 12px;
|
||||
color: $acc;
|
||||
}
|
||||
|
||||
.ai-suggestion-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.ai-risk-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-suggestion-text {
|
||||
font-size: 13px;
|
||||
color: $tx2;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, Input } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import { useHealthStore } from '../../stores/health';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { inputVitalSign, getTrend } from '../../services/health';
|
||||
import { listPendingSuggestions, type AiSuggestionItem } from '../../services/ai-analysis';
|
||||
import Loading from '../../components/Loading';
|
||||
import './index.scss';
|
||||
|
||||
@@ -41,12 +42,23 @@ export default function Health() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
|
||||
const [trendLoading, setTrendLoading] = useState(false);
|
||||
const [aiSuggestions, setAiSuggestions] = useState<AiSuggestionItem[]>([]);
|
||||
|
||||
useDidShow(() => {
|
||||
refreshToday();
|
||||
loadTrend(activeTab);
|
||||
loadAiSuggestions();
|
||||
});
|
||||
|
||||
const loadAiSuggestions = async () => {
|
||||
try {
|
||||
const items = await listPendingSuggestions();
|
||||
setAiSuggestions(items.slice(0, 3));
|
||||
} catch {
|
||||
// 静默
|
||||
}
|
||||
};
|
||||
|
||||
const loadTrend = async (type: VitalType) => {
|
||||
setTrendLoading(true);
|
||||
try {
|
||||
@@ -162,6 +174,28 @@ export default function Health() {
|
||||
<Text className='health-title'>健康数据</Text>
|
||||
</View>
|
||||
|
||||
{/* AI 建议卡片 */}
|
||||
{aiSuggestions.length > 0 && (
|
||||
<View className='ai-suggestion-card' onClick={() => Taro.navigateTo({ url: '/pages/pkg-profile/settings/index' })}>
|
||||
<View className='ai-card-header'>
|
||||
<Text className='ai-card-title'>AI 健康建议</Text>
|
||||
<Text className='ai-card-count'>{aiSuggestions.length} 条待查看</Text>
|
||||
</View>
|
||||
{aiSuggestions.map((s) => {
|
||||
const riskColor = s.risk_level === 'high' ? '#ef4444' : s.risk_level === 'medium' ? '#f59e0b' : '#22c55e';
|
||||
const typeLabel = s.suggestion_type === 'followup' ? '随访' : s.suggestion_type === 'appointment' ? '预约' : '预警';
|
||||
const params = s.params as Record<string, unknown> | null;
|
||||
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' style={{ background: riskColor }} />
|
||||
<Text className='ai-suggestion-text'>{reason.slice(0, 40)}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 类型 Tab */}
|
||||
<View className='vital-tabs'>
|
||||
{VITAL_TABS.map((tab) => {
|
||||
|
||||
@@ -22,3 +22,21 @@ export async function listAiAnalysis(page = 1, pageSize = 20) {
|
||||
export async function getAiAnalysisDetail(id: string) {
|
||||
return api.get<AiAnalysisItem>(`/ai/analysis/${id}`);
|
||||
}
|
||||
|
||||
export interface AiSuggestionItem {
|
||||
id: string;
|
||||
analysis_id: string;
|
||||
suggestion_type: string;
|
||||
risk_level: string;
|
||||
params: Record<string, unknown> | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function listPendingSuggestions() {
|
||||
const resp = await api.get<{ data: AiSuggestionItem[]; total: number }>(
|
||||
'/ai/suggestions',
|
||||
{ status: 'pending' },
|
||||
);
|
||||
return resp.data || [];
|
||||
}
|
||||
|
||||
34
apps/web/src/api/ai/suggestions.ts
Normal file
34
apps/web/src/api/ai/suggestions.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import client from '../client';
|
||||
|
||||
export interface SuggestionItem {
|
||||
id: string;
|
||||
analysis_id: string;
|
||||
suggestion_type: string;
|
||||
risk_level: string;
|
||||
params: Record<string, unknown> | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ComparisonReport {
|
||||
suggestion_id: string;
|
||||
baseline: Record<string, unknown> | null;
|
||||
current: Record<string, unknown> | null;
|
||||
comparison_available: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const suggestionApi = {
|
||||
list: async (params?: { analysis_id?: string; status?: string }) => {
|
||||
const resp = await client.get('/ai/suggestions', { params });
|
||||
return resp.data.data as { data: SuggestionItem[]; total: number };
|
||||
},
|
||||
approve: async (id: string, action: 'approve' | 'reject') => {
|
||||
const resp = await client.post(`/ai/suggestions/${id}/approve`, { action });
|
||||
return resp.data.data as { id: string; status: string };
|
||||
},
|
||||
getComparison: async (id: string) => {
|
||||
const resp = await client.get(`/ai/suggestions/${id}/comparison`);
|
||||
return resp.data.data as ComparisonReport;
|
||||
},
|
||||
};
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Table, Select, Tag, Space, message, Typography } from 'antd';
|
||||
import { Table, Select, Tag, Space, Button, message, Typography } from 'antd';
|
||||
import {
|
||||
RobotOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
import { analysisApi, type AnalysisItem } from '../../api/ai/analysis';
|
||||
import { suggestionApi, type SuggestionItem } from '../../api/ai/suggestions';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -27,6 +32,27 @@ const TYPE_OPTIONS = Object.entries(ANALYSIS_TYPE_MAP).map(([value, label]) => (
|
||||
label,
|
||||
}));
|
||||
|
||||
const RISK_CONFIG: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
|
||||
low: { color: 'green', text: '低风险', icon: <CheckCircleOutlined /> },
|
||||
medium: { color: 'orange', text: '中风险', icon: <ExclamationCircleOutlined /> },
|
||||
high: { color: 'red', text: '高风险', icon: <WarningOutlined /> },
|
||||
};
|
||||
|
||||
const SUGGESTION_TYPE_MAP: Record<string, string> = {
|
||||
followup: '随访建议',
|
||||
appointment: '预约建议',
|
||||
alert: '预警通知',
|
||||
};
|
||||
|
||||
const SUGGESTION_STATUS_CONFIG: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'orange', text: '待审批' },
|
||||
approved: { color: 'green', text: '已批准' },
|
||||
rejected: { color: 'red', text: '已拒绝' },
|
||||
executed: { color: 'blue', text: '已执行' },
|
||||
expired: { color: 'default', text: '已过期' },
|
||||
parse_failed: { color: 'red', text: '解析失败' },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 分析结果渲染(Markdown 风格)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -105,6 +131,117 @@ function renderInlineStyles(text: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI 建议面板
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SuggestionPanel({ analysisId, isDark }: { analysisId: string; isDark: boolean }) {
|
||||
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
const fetchSuggestions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await suggestionApi.list({ analysis_id: analysisId });
|
||||
setSuggestions(result.data || []);
|
||||
} catch {
|
||||
// 静默处理
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [analysisId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (analysisId) fetchSuggestions();
|
||||
}, [analysisId, fetchSuggestions]);
|
||||
|
||||
const handleAction = async (id: string, action: 'approve' | 'reject') => {
|
||||
setActionLoading(id);
|
||||
try {
|
||||
await suggestionApi.approve(id, action);
|
||||
message.success(action === 'approve' ? '已批准' : '已拒绝');
|
||||
fetchSuggestions();
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ padding: '8px 0' }}>加载建议中...</div>;
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${isDark ? '#1e293b' : '#e2e8f0'}`,
|
||||
}}>
|
||||
<Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
|
||||
AI 建议列表 ({suggestions.length})
|
||||
</Text>
|
||||
{suggestions.map((s) => {
|
||||
const risk = RISK_CONFIG[s.risk_level] || { color: 'default', text: s.risk_level, icon: null };
|
||||
const status = SUGGESTION_STATUS_CONFIG[s.status] || { color: 'default', text: s.status };
|
||||
const typeLabel = SUGGESTION_TYPE_MAP[s.suggestion_type] || s.suggestion_type;
|
||||
const isPending = s.status === 'pending';
|
||||
const params = s.params as Record<string, unknown> | null;
|
||||
const reason = params?.reason as string || params?.message as string || '';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
marginBottom: 6,
|
||||
background: isDark ? '#111827' : '#fff',
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${isDark ? '#1e293b' : '#f0f0f0'}`,
|
||||
}}
|
||||
>
|
||||
<Space size={8}>
|
||||
<Tag color={risk.color} style={{ margin: 0 }}>
|
||||
{risk.icon} {risk.text}
|
||||
</Tag>
|
||||
<Tag style={{ margin: 0 }}>{typeLabel}</Tag>
|
||||
{reason && <Text type="secondary" style={{ fontSize: 12, maxWidth: 300 }} ellipsis>{reason}</Text>}
|
||||
<Tag color={status.color} style={{ margin: 0, fontSize: 11 }}>{status.text}</Tag>
|
||||
</Space>
|
||||
{isPending && (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<CheckCircleOutlined />}
|
||||
loading={actionLoading === s.id}
|
||||
onClick={() => handleAction(s.id, 'approve')}
|
||||
>
|
||||
批准
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<CloseCircleOutlined />}
|
||||
loading={actionLoading === s.id}
|
||||
onClick={() => handleAction(s.id, 'reject')}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 主组件
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -297,6 +434,11 @@ export default function AiAnalysisList() {
|
||||
{!detail.result_content && !detail.error_message && (
|
||||
<Text type="secondary">暂无结果内容</Text>
|
||||
)}
|
||||
|
||||
{/* AI 建议面板 */}
|
||||
{detail.id && (
|
||||
<SuggestionPanel analysisId={detail.id} isDark={isDark} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -27,6 +27,9 @@ import { LabReportsTab } from './components/LabReportsTab';
|
||||
import { HealthRecordsTab } from './components/HealthRecordsTab';
|
||||
import { FollowUpTab } from './components/FollowUpTab';
|
||||
import { DeviceReadingsTab } from './components/DeviceReadingsTab';
|
||||
import { PointsAccountTab } from './components/PointsAccountTab';
|
||||
import { AiSuggestionTab } from './components/AiSuggestionTab';
|
||||
import { DailyMonitoringTab } from './components/DailyMonitoringTab';
|
||||
import { GENDER_OPTIONS, BLOOD_TYPE_OPTIONS } from '../../constants/health';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
|
||||
@@ -280,6 +283,7 @@ export default function PatientDetail() {
|
||||
{ key: 'device', label: '设备数据', children: <DeviceReadingsTab patientId={id} /> },
|
||||
{ key: 'lab', label: '化验报告', children: <LabReportsTab patientId={id} /> },
|
||||
{ key: 'records', label: '健康档案', children: <HealthRecordsTab patientId={id} /> },
|
||||
{ key: 'daily', label: '日常监测', children: <DailyMonitoringTab patientId={id} /> },
|
||||
]}
|
||||
/>
|
||||
) : null,
|
||||
@@ -289,6 +293,16 @@ export default function PatientDetail() {
|
||||
label: '随访记录',
|
||||
children: id ? <FollowUpTab patientId={id} /> : null,
|
||||
},
|
||||
{
|
||||
key: 'points',
|
||||
label: '积分账户',
|
||||
children: id ? <PointsAccountTab patientId={id} /> : null,
|
||||
},
|
||||
{
|
||||
key: 'ai',
|
||||
label: 'AI 建议',
|
||||
children: id ? <AiSuggestionTab patientId={id} /> : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
163
apps/web/src/pages/health/components/AiSuggestionTab.tsx
Normal file
163
apps/web/src/pages/health/components/AiSuggestionTab.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Table, Tag, Button, Space, message, Typography } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { suggestionApi, type SuggestionItem } from '../../../api/ai/suggestions';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const RISK_CONFIG: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
|
||||
low: { color: 'green', text: '低风险', icon: <CheckCircleOutlined /> },
|
||||
medium: { color: 'orange', text: '中风险', icon: <ExclamationCircleOutlined /> },
|
||||
high: { color: 'red', text: '高风险', icon: <WarningOutlined /> },
|
||||
};
|
||||
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
followup: '随访建议',
|
||||
appointment: '预约建议',
|
||||
alert: '预警通知',
|
||||
};
|
||||
|
||||
const STATUS_CONFIG: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'orange', text: '待审批' },
|
||||
approved: { color: 'green', text: '已批准' },
|
||||
rejected: { color: 'red', text: '已拒绝' },
|
||||
executed: { color: 'blue', text: '已执行' },
|
||||
expired: { color: 'default', text: '已过期' },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
patientId: string;
|
||||
}
|
||||
|
||||
export function AiSuggestionTab({ patientId }: Props) {
|
||||
const [data, setData] = useState<SuggestionItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 加载待审批的建议(后续可扩展为按患者过滤)
|
||||
const result = await suggestionApi.list({ status: 'pending' });
|
||||
setData(result.data || []);
|
||||
} catch {
|
||||
// 静默
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData, patientId]);
|
||||
|
||||
const handleAction = async (id: string, action: 'approve' | 'reject') => {
|
||||
setActionLoading(id);
|
||||
try {
|
||||
await suggestionApi.approve(id, action);
|
||||
message.success(action === 'approve' ? '已批准' : '已拒绝');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '风险等级',
|
||||
dataIndex: 'risk_level',
|
||||
key: 'risk_level',
|
||||
width: 100,
|
||||
render: (v: string) => {
|
||||
const cfg = RISK_CONFIG[v] || { color: 'default', text: v, icon: null };
|
||||
return <Tag color={cfg.color}>{cfg.icon} {cfg.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'suggestion_type',
|
||||
key: 'suggestion_type',
|
||||
width: 100,
|
||||
render: (v: string) => <Tag>{TYPE_MAP[v] || v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '建议原因',
|
||||
key: 'reason',
|
||||
render: (_: unknown, record: SuggestionItem) => {
|
||||
const params = record.params as Record<string, unknown> | null;
|
||||
return (
|
||||
<Text type="secondary" ellipsis style={{ maxWidth: 300 }}>
|
||||
{(params?.reason as string) || (params?.message as string) || '-'}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: (v: string) => {
|
||||
const cfg = STATUS_CONFIG[v] || { color: 'default', text: v };
|
||||
return <Tag color={cfg.color}>{cfg.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 160,
|
||||
render: (v: string) => v ? new Date(v).toLocaleString('zh-CN') : '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 160,
|
||||
render: (_: unknown, record: SuggestionItem) => {
|
||||
if (record.status !== 'pending') return null;
|
||||
return (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<CheckCircleOutlined />}
|
||||
loading={actionLoading === record.id}
|
||||
onClick={() => handleAction(record.id, 'approve')}
|
||||
>
|
||||
批准
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<CloseCircleOutlined />}
|
||||
loading={actionLoading === record.id}
|
||||
onClick={() => handleAction(record.id, 'reject')}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{ pageSize: 10, showTotal: (t) => `共 ${t} 条` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -87,3 +87,43 @@ where
|
||||
"status": new_status.as_str(),
|
||||
}))))
|
||||
}
|
||||
|
||||
/// 获取 AI 建议的前后对比报告。
|
||||
pub async fn get_comparison<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.suggestion.list")?;
|
||||
|
||||
use crate::entity::ai_suggestion;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
|
||||
let suggestion = ai_suggestion::Entity::find_by_id(id)
|
||||
.one(&state.db)
|
||||
.await
|
||||
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?
|
||||
.filter(|s| s.tenant_id == ctx.tenant_id && s.deleted_at.is_none())
|
||||
.ok_or_else(|| erp_core::error::AppError::NotFound("建议不存在".into()))?;
|
||||
|
||||
match &suggestion.baseline_snapshot {
|
||||
Some(bs) if !bs.is_null() => {
|
||||
let action_result = suggestion.action_result.as_ref().unwrap_or(&serde_json::Value::Null);
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"suggestion_id": id,
|
||||
"baseline": bs,
|
||||
"current": action_result,
|
||||
"comparison_available": !action_result.is_null(),
|
||||
}))))
|
||||
}
|
||||
_ => Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"suggestion_id": id,
|
||||
"comparison_available": false,
|
||||
"message": "该建议暂无 baseline 快照,无法生成对比报告",
|
||||
})))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,54 @@ impl ErpModule for AiModule {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
async fn on_startup(
|
||||
&self,
|
||||
ctx: &erp_core::module::ModuleContext,
|
||||
) -> erp_core::error::AppResult<()> {
|
||||
let (mut rx, _handle) = ctx.event_bus.subscribe_filtered("ai.reanalysis.".to_string());
|
||||
let db = ctx.db.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(event) if event.event_type == "ai.reanalysis.requested" => {
|
||||
let suggestion_id = event.payload.get("original_suggestion_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
let patient_id = event.payload.get("patient_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
|
||||
match (suggestion_id, patient_id) {
|
||||
(Some(sid), Some(pid)) => {
|
||||
if let Err(e) = crate::service::reanalysis::handle_reanalysis_requested(
|
||||
&db, event.tenant_id, sid, pid,
|
||||
).await {
|
||||
tracing::warn!(
|
||||
suggestion_id = %sid,
|
||||
error = %e,
|
||||
"AI 再分析处理失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("ai.reanalysis.requested 事件缺少必要字段");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
tracing::info!("AI 再分析事件订阅通道已关闭");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tracing::info!(module = "ai", "AI 模块事件处理器已注册(监听 reanalysis)");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl AiModule {
|
||||
@@ -148,5 +196,9 @@ impl AiModule {
|
||||
"/ai/suggestions/{id}/approve",
|
||||
axum::routing::post(crate::handler::suggestion_handler::approve_suggestion),
|
||||
)
|
||||
.route(
|
||||
"/ai/suggestions/{id}/comparison",
|
||||
axum::routing::get(crate::handler::suggestion_handler::get_comparison),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
128
crates/erp-ai/src/service/comparison.rs
Normal file
128
crates/erp-ai/src/service/comparison.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 趋势方向
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TrendDirection {
|
||||
Improving,
|
||||
Stable,
|
||||
Worsening,
|
||||
}
|
||||
|
||||
/// 单项指标变化
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MetricChange {
|
||||
pub metric: String,
|
||||
pub baseline_value: f64,
|
||||
pub current_value: f64,
|
||||
pub change_percent: f64,
|
||||
pub trend: TrendDirection,
|
||||
}
|
||||
|
||||
/// 前后对比报告
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ComparisonReport {
|
||||
pub baseline: serde_json::Value,
|
||||
pub current: serde_json::Value,
|
||||
pub changes: Vec<MetricChange>,
|
||||
pub overall_trend: TrendDirection,
|
||||
}
|
||||
|
||||
/// 对比 baseline 和当前数据生成变化报告。
|
||||
pub fn generate_comparison(
|
||||
baseline: &serde_json::Value,
|
||||
current: &serde_json::Value,
|
||||
) -> ComparisonReport {
|
||||
let mut changes = Vec::new();
|
||||
|
||||
// 提取可比较的数值指标
|
||||
if let (Some(b_obj), Some(c_obj)) = (baseline.as_object(), current.as_object()) {
|
||||
for key in b_obj.keys() {
|
||||
if let (Some(b_val), Some(c_val)) = (b_obj.get(key), c_obj.get(key)) {
|
||||
if let (Some(b_num), Some(c_num)) = (b_val.as_f64(), c_val.as_f64()) {
|
||||
let change_pct = if b_num.abs() > 0.0001 {
|
||||
((c_num - b_num) / b_num.abs()) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let trend = if change_pct.abs() > 5.0 {
|
||||
TrendDirection::Worsening
|
||||
} else {
|
||||
TrendDirection::Stable
|
||||
};
|
||||
changes.push(MetricChange {
|
||||
metric: key.clone(),
|
||||
baseline_value: b_num,
|
||||
current_value: c_num,
|
||||
change_percent: change_pct,
|
||||
trend,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 综合趋势判断
|
||||
let changed = changes.iter().filter(|c| c.trend == TrendDirection::Worsening).count();
|
||||
let overall = if changed > 0 {
|
||||
TrendDirection::Worsening
|
||||
} else {
|
||||
TrendDirection::Stable
|
||||
};
|
||||
|
||||
ComparisonReport {
|
||||
baseline: baseline.clone(),
|
||||
current: current.clone(),
|
||||
changes,
|
||||
overall_trend: overall,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generate_comparison_detects_significant_change() {
|
||||
let baseline = serde_json::json!({"systolic_bp": 160.0, "heart_rate": 95.0});
|
||||
let current = serde_json::json!({"systolic_bp": 130.0, "heart_rate": 80.0});
|
||||
let report = generate_comparison(&baseline, ¤t);
|
||||
assert_eq!(report.overall_trend, TrendDirection::Worsening);
|
||||
assert_eq!(report.changes.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_comparison_detects_single_change() {
|
||||
let baseline = serde_json::json!({"systolic_bp": 130.0});
|
||||
let current = serde_json::json!({"systolic_bp": 160.0});
|
||||
let report = generate_comparison(&baseline, ¤t);
|
||||
assert_eq!(report.overall_trend, TrendDirection::Worsening);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_comparison_detects_stable() {
|
||||
let baseline = serde_json::json!({"heart_rate": 75.0});
|
||||
let current = serde_json::json!({"heart_rate": 76.0});
|
||||
let report = generate_comparison(&baseline, ¤t);
|
||||
assert_eq!(report.overall_trend, TrendDirection::Stable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_comparison_empty_data() {
|
||||
let baseline = serde_json::json!({});
|
||||
let current = serde_json::json!({});
|
||||
let report = generate_comparison(&baseline, ¤t);
|
||||
assert_eq!(report.overall_trend, TrendDirection::Stable);
|
||||
assert!(report.changes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_comparison_mixed_metrics() {
|
||||
let baseline = serde_json::json!({"systolic_bp": 150.0, "heart_rate": 80.0, "spo2": 96.0});
|
||||
let current = serde_json::json!({"systolic_bp": 140.0, "heart_rate": 95.0, "spo2": 90.0});
|
||||
let report = generate_comparison(&baseline, ¤t);
|
||||
// bp: -6.7% changed, hr: +18.75% changed, spo2: -6.25% changed → has changes
|
||||
assert_eq!(report.overall_trend, TrendDirection::Worsening);
|
||||
assert_eq!(report.changes.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod analysis;
|
||||
pub mod auto_analysis;
|
||||
pub mod comparison;
|
||||
pub mod local_rules;
|
||||
pub mod output_parser;
|
||||
pub mod prompt;
|
||||
pub mod reanalysis;
|
||||
pub mod suggestion;
|
||||
pub mod usage;
|
||||
|
||||
59
crates/erp-ai/src/service/reanalysis.rs
Normal file
59
crates/erp-ai/src/service/reanalysis.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use sea_orm::{DatabaseConnection, FromQueryResult, Statement};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 再分析请求触发后,加载原始建议的 baseline。
|
||||
pub async fn handle_reanalysis_requested(
|
||||
db: &DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
original_suggestion_id: Uuid,
|
||||
patient_id: Uuid,
|
||||
) -> Result<(), sea_orm::DbErr> {
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct OriginalSuggestion {
|
||||
baseline_snapshot: Option<serde_json::Value>,
|
||||
params: Option<serde_json::Value>,
|
||||
risk_level: Option<String>,
|
||||
}
|
||||
|
||||
let sql = r#"
|
||||
SELECT baseline_snapshot, params, risk_level
|
||||
FROM ai_suggestion
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
"#;
|
||||
let original: Option<OriginalSuggestion> = OriginalSuggestion::find_by_statement(
|
||||
Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[original_suggestion_id.into(), tenant_id.into()],
|
||||
),
|
||||
)
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
match original {
|
||||
Some(orig) => {
|
||||
tracing::info!(
|
||||
suggestion_id = %original_suggestion_id,
|
||||
patient_id = %patient_id,
|
||||
has_baseline = orig.baseline_snapshot.is_some(),
|
||||
risk_level = ?orig.risk_level,
|
||||
"再分析:已加载原始建议 baseline"
|
||||
);
|
||||
// 后续在 comparison.rs 中实现完整对比逻辑
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
suggestion_id = %original_suggestion_id,
|
||||
"再分析:原始建议未找到"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn reanalysis_module_loads() {}
|
||||
}
|
||||
@@ -315,6 +315,49 @@ pub fn register_handlers_with_state(state: crate::state::HealthState) {
|
||||
}
|
||||
let _ = erp_core::events::mark_event_processed(&fu_db, event.id, "follow_up_escalator").await;
|
||||
}
|
||||
Some(event) if event.event_type == FOLLOW_UP_COMPLETED => {
|
||||
// 随访完成 → 检查是否由 AI 触发,触发再分析
|
||||
if let Some(task_id_str) = event.payload.get("task_id").and_then(|v| v.as_str()) {
|
||||
if let Ok(task_id) = uuid::Uuid::parse_str(task_id_str) {
|
||||
let patient_id = event.payload.get("patient_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||
|
||||
if let Some(patient_id) = patient_id {
|
||||
// 通过 raw SQL 查找关联的 AI 建议(action_result 中包含 followup_task_id)
|
||||
let sql = r#"
|
||||
SELECT id FROM ai_suggestion
|
||||
WHERE tenant_id = $1
|
||||
AND deleted_at IS NULL
|
||||
AND status = 'executed'
|
||||
AND action_result @> $2
|
||||
LIMIT 1
|
||||
"#;
|
||||
if let Some(suggestion_id) = crate::service::ai_suggestion_loader::find_by_followup_task(
|
||||
&fu_db, event.tenant_id, task_id,
|
||||
).await.unwrap_or(None) {
|
||||
let reanalysis_event = erp_core::events::DomainEvent::new(
|
||||
"ai.reanalysis.requested",
|
||||
event.tenant_id,
|
||||
erp_core::events::build_event_payload(serde_json::json!({
|
||||
"original_suggestion_id": suggestion_id.to_string(),
|
||||
"patient_id": patient_id.to_string(),
|
||||
"followup_task_id": task_id_str,
|
||||
"trigger": "loop_closure",
|
||||
})),
|
||||
);
|
||||
fu_bus.publish(reanalysis_event, &fu_db).await;
|
||||
tracing::info!(
|
||||
suggestion_id = %suggestion_id,
|
||||
patient_id = %patient_id,
|
||||
task_id = %task_id,
|
||||
"随访完成,触发 AI 再分析(闭环)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
|
||||
@@ -38,3 +38,34 @@ pub async fn load_by_analysis(
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct IdRow {
|
||||
id: Uuid,
|
||||
}
|
||||
|
||||
/// 通过随访任务 ID 反查关联的 AI 建议ID(action_result 中包含 followup_task_id)。
|
||||
pub async fn find_by_followup_task(
|
||||
db: &DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
followup_task_id: Uuid,
|
||||
) -> Result<Option<Uuid>, sea_orm::DbErr> {
|
||||
let row: Option<IdRow> = IdRow::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
r#"
|
||||
SELECT id FROM ai_suggestion
|
||||
WHERE tenant_id = $1
|
||||
AND deleted_at IS NULL
|
||||
AND status = 'executed'
|
||||
AND action_result @> $2
|
||||
LIMIT 1
|
||||
"#,
|
||||
[
|
||||
tenant_id.into(),
|
||||
serde_json::json!({"followup_task_id": followup_task_id.to_string()}).into(),
|
||||
],
|
||||
))
|
||||
.one(db)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.id))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user