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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user