feat(health+core+ai): 业务流程全面修复 Phase 4-6 + 集成测试修复

Phase 4 — Dead-letter 重试 + 内容推送 + 安全加固:
- erp-core: retry_dead_letters() 定时重试 + PII payload 脱敏
- erp-core: audit_service 哈希链定时验证 + 写入失败告警
- erp-health: article.published 消费者匹配 patient_tag 推送消息
- erp-health: care_plan 事件消费者 (激活通知 + 完成积分)

Phase 5 — 患者批量操作 + 咨询增强 + 护理事件:
- patient: batch_import_patients + bind_by_phone + refer_patient
- consultation: rate_session 满意度评价 (rating + feedback)
- consent: patient_sign_consent 患者端签署
- validation: source 枚举 (7值) + relationship 枚举 (7值) + 12 单元测试

Phase 6 — 咨询文件上传 + AI 引用标注:
- consultation_message: media_id 附件上传端点
- ai_suggestion: references JSONB + [ref:id] 格式引用标注
- AI system prompt 增加引用指令 + output_parser 提取逻辑

迁移: 000161 (media_id + references) + 000162 (rating + feedback)
集成测试: consultation/follow_up/pii_encryption 新字段同步修复
讨论文档: 2026-05-20-business-process-brainstorm.md (10域审核报告)
This commit is contained in:
iven
2026-05-21 01:34:20 +08:00
parent 9033ec8ca2
commit 41a865cf68
37 changed files with 1929 additions and 14 deletions

View File

@@ -15,6 +15,7 @@ pub struct Model {
pub workflow_instance_id: Option<Uuid>,
pub action_result: Option<serde_json::Value>,
pub baseline_snapshot: Option<serde_json::Value>,
pub references: Option<serde_json::Value>,
pub reanalysis_id: Option<Uuid>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,

View File

@@ -94,7 +94,12 @@ impl AnalysisService {
tracing::info!(analysis = %analysis_id, tenant = %tenant_id, r#type = %analysis_type.as_str(), "发起 AI 分析");
// 0.5 知识库上下文注入
// 0.5 知识库上下文注入 + 引用标注指令
let citation_instruction = "\n\n=== 引用标注规则 ===\n\
在回答中引用知识库条目时,请使用 [ref:id] 格式标注引用来源。\n\
例如:\"根据临床指南 [ref:uuid-of-guideline],建议...\"\n\
每个引用的知识库条目必须在回答中标注。如果没有引用任何知识库条目,则无需标注。";
let system_prompt = if let Some(ref ks) = self.knowledge_source {
let query = crate::knowledge::KnowledgeQuery {
tenant_id,
@@ -109,9 +114,20 @@ impl AnalysisService {
confidence = ctx.confidence,
"知识库上下文注入"
);
// 将引用的来源 ID 附加到上下文中
let refs_info = if ctx.references.is_empty() {
String::new()
} else {
let refs_list: Vec<String> = ctx
.references
.iter()
.map(|r| format!("- {} (ID: {})", r.title, r.source))
.collect();
format!("\n\n可用引用源:\n{}", refs_list.join("\n"))
};
format!(
"{}\n\n=== 知识库参考 ===\n{}",
system_prompt, ctx.context_text
"{}\n\n=== 知识库参考 ===\n{}{}{}",
system_prompt, ctx.context_text, refs_info, citation_instruction
)
}
Ok(_) => system_prompt,
@@ -121,7 +137,8 @@ impl AnalysisService {
}
}
} else {
system_prompt
// 无知识库时也添加引用指令(供通用场景使用)
format!("{}{}", system_prompt, citation_instruction)
};
// 1. 渲染 Prompt

View File

@@ -32,6 +32,29 @@ fn extract_section<'a>(raw: &'a str, start: &str, end: &str) -> Option<&'a str>
Some(&raw[content_start..content_end])
}
/// 从 AI 输出文本中提取 [ref:id] 格式的引用标注。
/// 返回所有匹配的引用 ID 列表(去重)。
pub fn extract_references(text: &str) -> Vec<String> {
let re = regex_lite::Regex::new(r"\[ref:([a-f0-9-]+)\]").unwrap_or_else(|_| {
// fallback: 不应该发生,但确保不 panic
panic!("引用提取正则编译失败");
});
let mut refs: Vec<String> = re
.captures_iter(text)
.filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
.collect();
refs.dedup();
refs
}
/// 从 AI 输出文本中移除 [ref:id] 标注,返回纯文本。
pub fn strip_references(text: &str) -> String {
let re = regex_lite::Regex::new(r"\[ref:[a-f0-9-]+\]").unwrap_or_else(|_| {
panic!("引用清除正则编译失败");
});
re.replace_all(text, "").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -78,4 +101,47 @@ mod tests {
assert!(!RiskLevel::Medium.is_auto_executable());
assert!(!RiskLevel::High.is_auto_executable());
}
// --- extract_references ---
#[test]
fn extract_single_reference() {
let text = "根据临床指南 [ref:01234567-abcd-ef01-2345-678901234567],建议...";
let refs = extract_references(text);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0], "01234567-abcd-ef01-2345-678901234567");
}
#[test]
fn extract_multiple_references() {
let text = "参考 [ref:aaa-bbb] 和 [ref:ccc-ddd],综合建议";
let refs = extract_references(text);
assert_eq!(refs.len(), 2);
}
#[test]
fn extract_no_references() {
let text = "纯文本,无引用标注";
let refs = extract_references(text);
assert!(refs.is_empty());
}
#[test]
fn extract_dedup_references() {
let text = "[ref:aaa-bbb] 再次引用 [ref:aaa-bbb]";
let refs = extract_references(text);
assert_eq!(refs.len(), 1);
}
#[test]
fn strip_references_removes_markers() {
let text = "根据指南 [ref:aaa-bbb],建议复查";
let clean = strip_references(text);
assert_eq!(clean, "根据指南 ,建议复查");
}
#[test]
fn strip_no_references_unchanged() {
let text = "无标注文本";
assert_eq!(strip_references(text), text);
}
}

View File

@@ -34,6 +34,14 @@ pub async fn post_process_analysis(
structured: None,
});
// 1.5 从完整 AI 输出中提取 [ref:id] 引用标注
let extracted_refs = output_parser::extract_references(full_content);
let references_json = if extracted_refs.is_empty() {
None
} else {
Some(serde_json::json!(extracted_refs))
};
// 2. 构建事件 payload
let mut event_payload = serde_json::json!({
"analysis_id": analysis_id,
@@ -42,6 +50,10 @@ pub async fn post_process_analysis(
"doctor_id": user_id,
});
if !extracted_refs.is_empty() {
event_payload["reference_count"] = serde_json::json!(extracted_refs.len());
}
let mut risk_level_str: Option<String> = None;
let mut suggestion_ids = Vec::new();
@@ -50,7 +62,7 @@ pub async fn post_process_analysis(
event_payload["risk_level"] = serde_json::json!(structured.risk_level.as_str());
event_payload["suggestion_count"] = serde_json::json!(structured.suggestions.len());
// 3. 创建建议记录
// 3. 创建建议记录(附带引用信息)
if !structured.suggestions.is_empty() {
match SuggestionService::create_suggestions(
&state.db,
@@ -60,6 +72,7 @@ pub async fn post_process_analysis(
structured.risk_level,
&structured.baseline_summary,
Some(user_id),
references_json.as_ref(),
)
.await
{

View File

@@ -8,6 +8,7 @@ pub struct SuggestionService;
impl SuggestionService {
/// 批量创建建议记录
#[allow(clippy::too_many_arguments)]
pub async fn create_suggestions(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
@@ -16,6 +17,7 @@ impl SuggestionService {
risk_level: RiskLevel,
baseline_snapshot: &serde_json::Value,
created_by: Option<Uuid>,
references: Option<&serde_json::Value>,
) -> AppResult<Vec<uuid::Uuid>> {
let mut ids = Vec::new();
for s in suggestions {
@@ -31,6 +33,7 @@ impl SuggestionService {
workflow_instance_id: Set(None),
action_result: Set(None),
baseline_snapshot: Set(Some(baseline_snapshot.clone())),
references: Set(references.cloned()),
reanalysis_id: Set(None),
created_by: Set(created_by),
updated_by: Set(created_by),