feat(ai): Phase 2A-3 随访页 AI 辅助生成小结 — SSE 端点 + 前端集成
- AnalysisType 新增 FollowUpSummary 变体(as_str/prompt_name) - HealthDataProvider 新增 get_follow_up_summary_data() + FollowUpSummaryDataDto - erp-health 实现随访数据查询(task + records + PII 解密) - 新增 /ai/analyze/follow-up-summary SSE 端点 - SanitizationService 新增 sanitize_follow_up_data() - 前端 analysisSse.ts/AiAnalysisCard 支持 follow-up-summary 类型 - FollowUpTaskList 操作列新增「AI 小结」按钮
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
export type AnalysisType = 'lab-report' | 'trends' | 'checkup-plan' | 'report-summary';
|
||||
export type AnalysisType = 'lab-report' | 'trends' | 'checkup-plan' | 'report-summary' | 'follow-up-summary';
|
||||
|
||||
interface AnalyzeBody {
|
||||
report_id?: string;
|
||||
patient_id?: string;
|
||||
metrics?: string[];
|
||||
source_id?: string;
|
||||
}
|
||||
|
||||
const ENDPOINT_MAP: Record<AnalysisType, string> = {
|
||||
@@ -11,6 +12,7 @@ const ENDPOINT_MAP: Record<AnalysisType, string> = {
|
||||
'trends': '/ai/analyze/trends',
|
||||
'checkup-plan': '/ai/analyze/checkup-plan',
|
||||
'report-summary': '/ai/analyze/report-summary',
|
||||
'follow-up-summary': '/ai/analyze/follow-up-summary',
|
||||
};
|
||||
|
||||
export interface SseCallbacks {
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface AiAnalysisCardProps {
|
||||
triggerLabel?: string;
|
||||
permission?: string;
|
||||
metrics?: string[];
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
type AnalysisState = 'idle' | 'loading' | 'success' | 'error';
|
||||
@@ -21,6 +22,7 @@ export function AiAnalysisCard({
|
||||
triggerLabel = 'AI 分析',
|
||||
permission = 'ai.analysis.manage',
|
||||
metrics,
|
||||
taskId,
|
||||
}: AiAnalysisCardProps) {
|
||||
const [state, setState] = useState<AnalysisState>('idle');
|
||||
const [content, setContent] = useState('');
|
||||
@@ -38,6 +40,9 @@ export function AiAnalysisCard({
|
||||
if (analysisType === 'trends' || analysisType === 'checkup-plan') {
|
||||
body.patient_id = sourceRef;
|
||||
}
|
||||
if (analysisType === 'follow-up-summary') {
|
||||
body.source_id = taskId || sourceRef;
|
||||
}
|
||||
if (metrics) {
|
||||
body.metrics = metrics;
|
||||
}
|
||||
@@ -55,7 +60,7 @@ export function AiAnalysisCard({
|
||||
setErrorMsg('分析请求失败');
|
||||
setState('error');
|
||||
}
|
||||
}, [analysisType, sourceRef, metrics]);
|
||||
}, [analysisType, sourceRef, metrics, taskId]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setState('idle');
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Space,
|
||||
Popconfirm,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, EditOutlined, SwapOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, EditOutlined, SwapOutlined, DeleteOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||
import { dayjs } from '../../utils/dayjs';
|
||||
import { followUpApi, type FollowUpTask, type CreateFollowUpTaskReq, type UpdateFollowUpTaskReq } from '../../api/health/followUp';
|
||||
@@ -26,6 +26,7 @@ import { formatDate, formatDateTime } from '../../utils/format';
|
||||
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
||||
import { useApiRequest } from '../../hooks/useApiRequest';
|
||||
import { useDictionary } from '../../hooks/useDictionary';
|
||||
import { AiAnalysisCard } from '../../components/ai/AiAnalysisCard';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'pending', label: '待处理' },
|
||||
@@ -117,6 +118,9 @@ export default function FollowUpTaskList() {
|
||||
const [assignForm] = Form.useForm<AssignFormValues>();
|
||||
const [assignTask, setAssignTask] = useState<FollowUpTask | null>(null);
|
||||
|
||||
// AI summary state
|
||||
const [aiSummaryTaskId, setAiSummaryTaskId] = useState<string | null>(null);
|
||||
|
||||
// --- Handlers ---
|
||||
const handleTableChange = (pagination: TablePaginationConfig) => {
|
||||
refresh(pagination.current ?? 1);
|
||||
@@ -280,7 +284,7 @@ export default function FollowUpTaskList() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 220,
|
||||
width: 280,
|
||||
render: (_: unknown, record: FollowUpTask) => (
|
||||
<AuthButton code="health.follow-up.manage">
|
||||
<Space size={4}>
|
||||
@@ -292,6 +296,14 @@ export default function FollowUpTaskList() {
|
||||
>
|
||||
填写记录
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => setAiSummaryTaskId(aiSummaryTaskId === record.id ? null : record.id)}
|
||||
>
|
||||
AI 小结
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -394,6 +406,18 @@ export default function FollowUpTaskList() {
|
||||
scroll={{ x: 980 }}
|
||||
/>
|
||||
|
||||
{/* AI 随访小结 */}
|
||||
{aiSummaryTaskId && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<AiAnalysisCard
|
||||
analysisType="follow-up-summary"
|
||||
sourceRef={aiSummaryTaskId}
|
||||
taskId={aiSummaryTaskId}
|
||||
triggerLabel="AI 生成随访小结"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Task Modal */}
|
||||
<Modal
|
||||
title="新建随访任务"
|
||||
|
||||
@@ -19,6 +19,7 @@ pub enum AnalysisType {
|
||||
Trends,
|
||||
CheckupPlan,
|
||||
ReportSummary,
|
||||
FollowUpSummary,
|
||||
}
|
||||
|
||||
impl AnalysisType {
|
||||
@@ -28,6 +29,7 @@ impl AnalysisType {
|
||||
Self::Trends => "trend",
|
||||
Self::CheckupPlan => "checkup_plan",
|
||||
Self::ReportSummary => "report_summary",
|
||||
Self::FollowUpSummary => "follow_up_summary",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +39,7 @@ impl AnalysisType {
|
||||
Self::Trends => "health_trend_analysis",
|
||||
Self::CheckupPlan => "personalized_checkup_plan",
|
||||
Self::ReportSummary => "report_summary_generation",
|
||||
Self::FollowUpSummary => "follow_up_summary_generation",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,6 +164,7 @@ mod tests {
|
||||
assert_eq!(AnalysisType::Trends.as_str(), "trend");
|
||||
assert_eq!(AnalysisType::CheckupPlan.as_str(), "checkup_plan");
|
||||
assert_eq!(AnalysisType::ReportSummary.as_str(), "report_summary");
|
||||
assert_eq!(AnalysisType::FollowUpSummary.as_str(), "follow_up_summary");
|
||||
}
|
||||
|
||||
// ---- AnalysisType::prompt_name ----
|
||||
@@ -180,6 +184,10 @@ mod tests {
|
||||
AnalysisType::ReportSummary.prompt_name(),
|
||||
"report_summary_generation"
|
||||
);
|
||||
assert_eq!(
|
||||
AnalysisType::FollowUpSummary.prompt_name(),
|
||||
"follow_up_summary_generation"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- AnalysisType serde round-trip ----
|
||||
@@ -191,6 +199,7 @@ mod tests {
|
||||
AnalysisType::Trends,
|
||||
AnalysisType::CheckupPlan,
|
||||
AnalysisType::ReportSummary,
|
||||
AnalysisType::FollowUpSummary,
|
||||
];
|
||||
for t in types {
|
||||
let json = serde_json::to_string(&t).unwrap();
|
||||
|
||||
@@ -52,6 +52,7 @@ pub struct AnalyzeBody {
|
||||
pub report_id: Option<uuid::Uuid>,
|
||||
pub patient_id: Option<uuid::Uuid>,
|
||||
pub metrics: Option<Vec<String>>,
|
||||
pub source_id: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
// === SSE 分析端点 ===
|
||||
@@ -378,6 +379,83 @@ where
|
||||
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/ai/analyze/follow-up-summary",
|
||||
request_body = AnalyzeBody,
|
||||
responses((status = 200, description = "SSE 随访小结生成流")),
|
||||
tag = "AI 分析",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn stream_follow_up_summary<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<AnalyzeBody>,
|
||||
) -> Result<Sse<impl futures::Stream<Item = Result<Event, Infallible>>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.analysis.manage")?;
|
||||
let task_id = body
|
||||
.source_id
|
||||
.ok_or_else(|| erp_core::error::AppError::Validation("source_id (task_id) 必填".into()))?;
|
||||
|
||||
let data = state
|
||||
.health_provider
|
||||
.get_follow_up_summary_data(ctx.tenant_id, task_id)
|
||||
.await?;
|
||||
|
||||
if data.records.is_empty() {
|
||||
return Err(erp_core::error::AppError::Validation(
|
||||
"该随访任务尚无随访记录,无法生成小结。请先填写至少一条随访记录。".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let sanitized_data = state.analysis.sanitizer.sanitize_follow_up_data(&data)?;
|
||||
|
||||
let prompt = state
|
||||
.prompt
|
||||
.get_active_prompt(ctx.tenant_id, "follow_up_summary_generation")
|
||||
.await?;
|
||||
|
||||
let model_config = &prompt.model_config;
|
||||
let (model, temperature, max_tokens) =
|
||||
resolve_model_config(model_config, ctx.tenant_id, &state.db).await;
|
||||
|
||||
let (stream, analysis_id, _) = state
|
||||
.analysis
|
||||
.stream_analyze(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
data.patient_id,
|
||||
AnalysisType::FollowUpSummary,
|
||||
task_id.to_string(),
|
||||
prompt.system_prompt,
|
||||
prompt.user_prompt_template,
|
||||
sanitized_data,
|
||||
model,
|
||||
temperature,
|
||||
max_tokens,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let analysis_id_clone = analysis_id;
|
||||
let state_clone = state.clone();
|
||||
let patient_id_clone = data.patient_id;
|
||||
|
||||
let sse_stream = build_sse_stream(
|
||||
stream,
|
||||
analysis_id_clone,
|
||||
state_clone,
|
||||
"follow_up_summary",
|
||||
ctx.tenant_id,
|
||||
patient_id_clone,
|
||||
ctx.user_id,
|
||||
);
|
||||
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
// === 分析历史 ===
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::IntoParams)]
|
||||
|
||||
@@ -426,6 +426,10 @@ impl AiModule {
|
||||
"/ai/analyze/report-summary",
|
||||
axum::routing::post(crate::handler::stream_report_summary),
|
||||
)
|
||||
.route(
|
||||
"/ai/analyze/follow-up-summary",
|
||||
axum::routing::post(crate::handler::stream_follow_up_summary),
|
||||
)
|
||||
.route(
|
||||
"/ai/analysis/history",
|
||||
axum::routing::get(crate::handler::list_analysis),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use erp_core::health_provider::{
|
||||
HealthReportDto, LabReportDto, PatientSummaryDto, TrendAnalysisDto, VitalSignDto,
|
||||
FollowUpSummaryDataDto, HealthReportDto, LabReportDto, PatientSummaryDto, TrendAnalysisDto,
|
||||
VitalSignDto,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -56,6 +57,13 @@ impl SanitizationService {
|
||||
Ok(sanitized)
|
||||
}
|
||||
|
||||
pub fn sanitize_follow_up_data(&self, data: &FollowUpSummaryDataDto) -> AiResult<Value> {
|
||||
let sanitized = serde_json::to_value(data)
|
||||
.map_err(|e| AiError::SanitizationError(format!("序列化失败: {e}")))?;
|
||||
self.verify_no_pii(&sanitized)?;
|
||||
Ok(sanitized)
|
||||
}
|
||||
|
||||
/// 二次验证: 确保没有意外泄漏的 PII
|
||||
fn verify_no_pii(&self, value: &Value) -> AiResult<()> {
|
||||
let pii_keys = [
|
||||
|
||||
@@ -9,9 +9,9 @@ use erp_ai::dto::{AgentGenerateResponse, ChatMessage, ChatMessageRole, ToolCall,
|
||||
use erp_ai::error::AiResult;
|
||||
use erp_ai::provider::AiProvider;
|
||||
use erp_core::health_provider::{
|
||||
AppointmentSummaryDto, HealthDataProvider, HealthReportDto, LabItemDto, LabReportDto,
|
||||
LabReportListItemDto, MedicationSummaryDto, PatientSummaryDto, TimeRange, TrendAnalysisDto,
|
||||
VitalSignDto,
|
||||
AppointmentSummaryDto, FollowUpSummaryDataDto, HealthDataProvider, HealthReportDto, LabItemDto,
|
||||
LabReportDto, LabReportListItemDto, MedicationSummaryDto, PatientSummaryDto, TimeRange,
|
||||
TrendAnalysisDto, VitalSignDto,
|
||||
};
|
||||
use futures::Stream;
|
||||
use std::pin::Pin;
|
||||
@@ -195,6 +195,13 @@ impl HealthDataProvider for MockHealthDataProvider {
|
||||
) -> erp_core::error::AppResult<Vec<LabReportListItemDto>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_follow_up_summary_data(
|
||||
&self,
|
||||
_tenant_id: Uuid,
|
||||
_task_id: Uuid,
|
||||
) -> erp_core::error::AppResult<FollowUpSummaryDataDto> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
// === 测试 ===
|
||||
|
||||
@@ -61,6 +61,13 @@ pub trait HealthDataProvider: Send + Sync {
|
||||
patient_id: Uuid,
|
||||
limit: u64,
|
||||
) -> AppResult<Vec<LabReportListItemDto>>;
|
||||
|
||||
/// 获取随访摘要数据(任务 + 已有记录,用于 AI 生成小结)
|
||||
async fn get_follow_up_summary_data(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
task_id: Uuid,
|
||||
) -> AppResult<FollowUpSummaryDataDto>;
|
||||
}
|
||||
|
||||
// === DTO 定义 ===
|
||||
@@ -200,3 +207,22 @@ pub struct LabReportListItemDto {
|
||||
pub report_date: String,
|
||||
pub abnormal_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FollowUpSummaryDataDto {
|
||||
pub task_id: Uuid,
|
||||
pub patient_id: Uuid,
|
||||
pub follow_up_type: String,
|
||||
pub planned_date: String,
|
||||
pub task_status: String,
|
||||
pub records: Vec<FollowUpRecordDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FollowUpRecordDto {
|
||||
pub executed_date: String,
|
||||
pub result: String,
|
||||
pub patient_condition: Option<String>,
|
||||
pub medical_advice: Option<String>,
|
||||
pub next_follow_up_date: Option<String>,
|
||||
}
|
||||
|
||||
@@ -3,17 +3,18 @@ use chrono::Datelike;
|
||||
use erp_core::crypto::{self as pii, PiiCrypto};
|
||||
use erp_core::error::{AppError, AppResult};
|
||||
use erp_core::health_provider::{
|
||||
AnomalyInfo, AppointmentSummaryDto, HealthDataProvider, HealthReportDto, LabItemDto,
|
||||
LabReportDto, LabReportListItemDto, MedicationSummaryDto, MetricTrendAnalysis,
|
||||
PatientSummaryDto, RegressionStats, ReportSectionDto, TimeRange, TrendAnalysisDto,
|
||||
TrendDirection, VitalSignDto,
|
||||
AnomalyInfo, AppointmentSummaryDto, FollowUpRecordDto, FollowUpSummaryDataDto,
|
||||
HealthDataProvider, HealthReportDto, LabItemDto, LabReportDto, LabReportListItemDto,
|
||||
MedicationSummaryDto, MetricTrendAnalysis, PatientSummaryDto, RegressionStats,
|
||||
ReportSectionDto, TimeRange, TrendAnalysisDto, TrendDirection, VitalSignDto,
|
||||
};
|
||||
use num_traits::ToPrimitive;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entity::{
|
||||
appointment, diagnosis, health_record, lab_report, medication_record, patient, vital_signs,
|
||||
appointment, diagnosis, follow_up_record, follow_up_task, health_record, lab_report,
|
||||
medication_record, patient, vital_signs,
|
||||
};
|
||||
|
||||
pub struct HealthDataProviderImpl {
|
||||
@@ -668,4 +669,50 @@ impl HealthDataProvider for HealthDataProviderImpl {
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn get_follow_up_summary_data(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
task_id: Uuid,
|
||||
) -> AppResult<FollowUpSummaryDataDto> {
|
||||
let task = follow_up_task::Entity::find_by_id(task_id)
|
||||
.filter(follow_up_task::Column::TenantId.eq(tenant_id))
|
||||
.filter(follow_up_task::Column::DeletedAt.is_null())
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("随访任务 {task_id} 不存在")))?;
|
||||
|
||||
let records = follow_up_record::Entity::find()
|
||||
.filter(follow_up_record::Column::TaskId.eq(task_id))
|
||||
.filter(follow_up_record::Column::DeletedAt.is_null())
|
||||
.order_by_asc(follow_up_record::Column::ExecutedDate)
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
|
||||
let kek = self.crypto.kek();
|
||||
let record_dtos: Vec<FollowUpRecordDto> = records
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let decrypt_text = |enc: &str| -> String {
|
||||
pii::decrypt(kek, enc).unwrap_or_else(|_| enc.to_string())
|
||||
};
|
||||
FollowUpRecordDto {
|
||||
executed_date: r.executed_date.to_string(),
|
||||
result: pii::decrypt(kek, &r.result).unwrap_or_else(|_| r.result.clone()),
|
||||
patient_condition: r.patient_condition.as_deref().map(decrypt_text),
|
||||
medical_advice: r.medical_advice.as_deref().map(decrypt_text),
|
||||
next_follow_up_date: r.next_follow_up_date.map(|d| d.to_string()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(FollowUpSummaryDataDto {
|
||||
task_id: task.id,
|
||||
patient_id: task.patient_id,
|
||||
follow_up_type: task.follow_up_type,
|
||||
planned_date: task.planned_date.to_string(),
|
||||
task_status: task.status,
|
||||
records: record_dtos,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user