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:
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user