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

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