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:
iven
2026-05-19 00:54:15 +08:00
parent 205f6fb5a2
commit 2660f1afff
10 changed files with 223 additions and 13 deletions

View File

@@ -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 {

View File

@@ -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');

View File

@@ -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="新建随访任务"

View File

@@ -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();

View File

@@ -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)]

View File

@@ -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),

View File

@@ -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 = [

View File

@@ -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!()
}
}
// === 测试 ===

View File

@@ -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>,
}

View File

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