From e0e4a7f9a1c4093a2bf2715ea27aead5068687f2 Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 25 Apr 2026 13:55:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E6=95=B0=E6=8D=AE=E8=84=B1?= =?UTF-8?q?=E6=95=8F=E6=9C=8D=E5=8A=A1=20+=20Prompt=20=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- crates/erp-ai/src/lib.rs | 2 + crates/erp-ai/src/prompt/mod.rs | 25 ++++++++++ crates/erp-ai/src/sanitization/mod.rs | 67 +++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 crates/erp-ai/src/prompt/mod.rs create mode 100644 crates/erp-ai/src/sanitization/mod.rs diff --git a/crates/erp-ai/src/lib.rs b/crates/erp-ai/src/lib.rs index c4b3d26..d80659c 100644 --- a/crates/erp-ai/src/lib.rs +++ b/crates/erp-ai/src/lib.rs @@ -1,6 +1,8 @@ pub mod dto; pub mod entity; pub mod error; +pub mod prompt; pub mod provider; +pub mod sanitization; pub use error::{AiError, AiResult}; diff --git a/crates/erp-ai/src/prompt/mod.rs b/crates/erp-ai/src/prompt/mod.rs new file mode 100644 index 0000000..e4ba7ad --- /dev/null +++ b/crates/erp-ai/src/prompt/mod.rs @@ -0,0 +1,25 @@ +use handlebars::Handlebars; +use serde_json::Value; + +use crate::error::{AiError, AiResult}; + +/// Prompt 模板渲染引擎 +pub struct PromptRenderer { + registry: Handlebars<'static>, +} + +impl PromptRenderer { + pub fn new() -> Self { + let mut registry = Handlebars::new(); + registry.set_strict_mode(true); + Self { registry } + } + + /// 渲染 Prompt 模板 — 使用 Handlebars {{variable}} 语法 + /// JSON 序列化注入,不做字符串拼接,防止 Prompt 注入 + pub fn render(&self, template: &str, data: &Value) -> AiResult { + self.registry + .render_template(template, data) + .map_err(|e| AiError::TemplateError(format!("模板渲染失败: {e}"))) + } +} diff --git a/crates/erp-ai/src/sanitization/mod.rs b/crates/erp-ai/src/sanitization/mod.rs new file mode 100644 index 0000000..9875a6e --- /dev/null +++ b/crates/erp-ai/src/sanitization/mod.rs @@ -0,0 +1,67 @@ +use erp_core::health_provider::{ + HealthReportDto, LabReportDto, PatientSummaryDto, VitalSignDto, +}; +use serde_json::Value; + +use crate::error::{AiError, AiResult}; + +/// 数据脱敏服务 — 确保发送给 AI 的数据不含 PII +/// HealthDataProvider 返回的 DTO 已经是脱敏的(只有年龄/性别/医疗数据) +/// 此服务做二次检查和安全约束注入 +pub struct SanitizationService; + +impl SanitizationService { + pub fn new() -> Self { + Self + } + + pub fn sanitize_lab_report(&self, report: &LabReportDto) -> AiResult { + let sanitized = serde_json::to_value(report) + .map_err(|e| AiError::SanitizationError(format!("序列化失败: {e}")))?; + self.verify_no_pii(&sanitized)?; + Ok(sanitized) + } + + pub fn sanitize_vital_signs(&self, signs: &[VitalSignDto]) -> AiResult { + let sanitized = serde_json::to_value(signs) + .map_err(|e| AiError::SanitizationError(format!("序列化失败: {e}")))?; + self.verify_no_pii(&sanitized)?; + Ok(sanitized) + } + + pub fn sanitize_patient_summary(&self, summary: &PatientSummaryDto) -> AiResult { + let sanitized = serde_json::to_value(summary) + .map_err(|e| AiError::SanitizationError(format!("序列化失败: {e}")))?; + self.verify_no_pii(&sanitized)?; + Ok(sanitized) + } + + pub fn sanitize_health_report(&self, report: &HealthReportDto) -> AiResult { + let sanitized = serde_json::to_value(report) + .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 = [ + "name", + "phone", + "id_number", + "address", + "birth_date", + "email", + ]; + if let Value::Object(map) = value { + for key in pii_keys { + if map.contains_key(key) { + return Err(AiError::SanitizationError(format!( + "检测到疑似 PII 字段: {key}" + ))); + } + } + } + Ok(()) + } +}