1use crate::{CloakError, EntityType, PiiEntity, Result, Scanner};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7pub struct PromptSanitizer {
37 scanner: Scanner,
38}
39
40impl PromptSanitizer {
41 #[must_use]
43 pub fn new(scanner: Scanner) -> Self {
44 Self { scanner }
45 }
46
47 pub fn sanitize(&self, prompt: &str) -> Result<(String, PromptMapping)> {
49 let scan = self.scanner.scan(prompt)?;
50 let mut findings = scan.findings;
51 findings.sort_by_key(|finding| finding.span.start);
52
53 let mut counters: HashMap<String, usize> = HashMap::new();
54 let mut entries = Vec::with_capacity(findings.len());
55 for finding in &findings {
56 validate_span(prompt, finding)?;
57 let prefix = placeholder_prefix(&finding.entity_type);
58 let next = counters.entry(prefix.clone()).or_insert(0);
59 *next += 1;
60 entries.push(PromptMappingEntry {
61 placeholder: format!("[{prefix}_{next}]"),
62 entity_type: finding.entity_type.clone(),
63 original: finding.text.clone(),
64 span_start: finding.span.start,
65 span_end: finding.span.end,
66 confidence: finding.confidence.value(),
67 recognizer_id: finding.recognizer_id.clone(),
68 });
69 }
70
71 let mut clean_prompt = prompt.to_string();
72 for (finding, entry) in findings.iter().zip(entries.iter()).rev() {
73 clean_prompt.replace_range(finding.span.start..finding.span.end, &entry.placeholder);
74 }
75
76 Ok((clean_prompt, PromptMapping { entries }))
77 }
78
79 #[must_use]
81 pub fn restore(&self, text: &str, mapping: &PromptMapping) -> String {
82 mapping.restore(text)
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct PromptMapping {
89 pub entries: Vec<PromptMappingEntry>,
91}
92
93impl PromptMapping {
94 #[must_use]
96 pub fn restore(&self, text: &str) -> String {
97 let mut result = text.to_string();
98 let mut entries = self.entries.clone();
99 entries.sort_by(|left, right| {
100 right
101 .placeholder
102 .len()
103 .cmp(&left.placeholder.len())
104 .then_with(|| right.placeholder.cmp(&left.placeholder))
105 });
106 for entry in entries {
107 result = result.replace(&entry.placeholder, &entry.original);
108 }
109 result
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
115pub struct PromptMappingEntry {
116 pub placeholder: String,
118 pub entity_type: EntityType,
120 pub original: String,
122 pub span_start: usize,
124 pub span_end: usize,
126 pub confidence: f64,
128 pub recognizer_id: String,
130}
131
132fn placeholder_prefix(entity_type: &EntityType) -> String {
133 entity_type
134 .redaction_tag()
135 .trim_start_matches('[')
136 .trim_end_matches(']')
137 .to_string()
138}
139
140fn validate_span(text: &str, finding: &PiiEntity) -> Result<()> {
141 let start = finding.span.start;
142 let end = finding.span.end;
143 if start <= end
144 && end <= text.len()
145 && text.is_char_boundary(start)
146 && text.is_char_boundary(end)
147 {
148 Ok(())
149 } else {
150 Err(CloakError::InvalidSpan {
151 start,
152 end,
153 len: text.len(),
154 })
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use crate::{Confidence, Locale, Recognizer, Span};
162
163 struct MultiRecognizer;
164
165 impl Recognizer for MultiRecognizer {
166 fn id(&self) -> &str {
167 "multi_test_v1"
168 }
169
170 fn entity_type(&self) -> EntityType {
171 EntityType::Email
172 }
173
174 fn supported_locales(&self) -> &[Locale] {
175 &[]
176 }
177
178 fn scan(&self, text: &str) -> Vec<PiiEntity> {
179 let mut findings = Vec::new();
180 for needle in ["jane@example.com", "john@example.com", "+1 555 010 1234"] {
181 if let Some(start) = text.find(needle) {
182 findings.push(PiiEntity {
183 entity_type: if needle.starts_with('+') {
184 EntityType::PhoneNumber
185 } else {
186 EntityType::Email
187 },
188 span: Span::new(start, start + needle.len()),
189 text: needle.to_string(),
190 confidence: Confidence::new(0.95).unwrap(),
191 recognizer_id: self.id().to_string(),
192 });
193 }
194 }
195 findings
196 }
197 }
198
199 #[test]
200 fn test_prompt_sanitizer_replaces_with_numbered_placeholders() {
201 let scanner = Scanner::builder()
202 .recognizer(MultiRecognizer)
203 .build()
204 .unwrap();
205 let sanitizer = PromptSanitizer::new(scanner);
206 let (clean, mapping) = sanitizer
207 .sanitize("Email jane@example.com and john@example.com")
208 .unwrap();
209 assert_eq!(clean, "Email [EMAIL_1] and [EMAIL_2]");
210 assert_eq!(mapping.entries[0].original, "jane@example.com");
211 assert_eq!(mapping.entries[1].placeholder, "[EMAIL_2]");
212 }
213
214 #[test]
215 fn test_prompt_sanitizer_restore_reinjects_original_values() {
216 let scanner = Scanner::builder()
217 .recognizer(MultiRecognizer)
218 .build()
219 .unwrap();
220 let sanitizer = PromptSanitizer::new(scanner);
221 let (_, mapping) = sanitizer
222 .sanitize("Call +1 555 010 1234 or email jane@example.com")
223 .unwrap();
224 let restored = sanitizer.restore("Use [PHONE_1] and [EMAIL_1]", &mapping);
225 assert_eq!(restored, "Use +1 555 010 1234 and jane@example.com");
226 }
227
228 #[test]
229 fn test_prompt_mapping_restore_replaces_repeated_placeholders() {
230 let mapping = PromptMapping {
231 entries: vec![PromptMappingEntry {
232 placeholder: "[EMAIL_1]".to_string(),
233 entity_type: EntityType::Email,
234 original: "jane@example.com".to_string(),
235 span_start: 0,
236 span_end: 16,
237 confidence: 0.95,
238 recognizer_id: "test".to_string(),
239 }],
240 };
241 assert_eq!(
242 mapping.restore("[EMAIL_1] replied to [EMAIL_1]"),
243 "jane@example.com replied to jane@example.com"
244 );
245 }
246}