Skip to main content

cloakrs_core/
prompt.rs

1//! Prompt sanitization helpers for LLM pipelines.
2
3use crate::{CloakError, EntityType, PiiEntity, Result, Scanner};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Sanitizes text before it is sent to an LLM, then restores placeholders later.
8///
9/// # Examples
10///
11/// ```
12/// use cloakrs_core::{Confidence, EntityType, Locale, PiiEntity, PromptSanitizer, Recognizer, Scanner, Span};
13///
14/// struct Email;
15/// impl Recognizer for Email {
16///     fn id(&self) -> &str { "email_test" }
17///     fn entity_type(&self) -> EntityType { EntityType::Email }
18///     fn supported_locales(&self) -> &[Locale] { &[] }
19///     fn scan(&self, text: &str) -> Vec<PiiEntity> {
20///         text.find('@').map(|at| PiiEntity {
21///             entity_type: EntityType::Email,
22///             span: Span::new(0, text[at..].find(' ').map_or(text.len(), |offset| at + offset)),
23///             text: text[..text[at..].find(' ').map_or(text.len(), |offset| at + offset)].to_string(),
24///             confidence: Confidence::new(0.95).unwrap(),
25///             recognizer_id: self.id().to_string(),
26///         }).into_iter().collect()
27///     }
28/// }
29///
30/// let scanner = Scanner::builder().recognizer(Email).build().unwrap();
31/// let sanitizer = PromptSanitizer::new(scanner);
32/// let (clean, mapping) = sanitizer.sanitize("jane@example.com said hi").unwrap();
33/// assert_eq!(clean, "[EMAIL_1] said hi");
34/// assert_eq!(sanitizer.restore("Reply to [EMAIL_1]", &mapping), "Reply to jane@example.com");
35/// ```
36pub struct PromptSanitizer {
37    scanner: Scanner,
38}
39
40impl PromptSanitizer {
41    /// Creates a prompt sanitizer from an existing scanner.
42    #[must_use]
43    pub fn new(scanner: Scanner) -> Self {
44        Self { scanner }
45    }
46
47    /// Replaces PII with numbered placeholders and returns the mapping table.
48    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    /// Restores placeholders in model output using a previously returned mapping table.
80    #[must_use]
81    pub fn restore(&self, text: &str, mapping: &PromptMapping) -> String {
82        mapping.restore(text)
83    }
84}
85
86/// Placeholder mapping returned by [`PromptSanitizer::sanitize`].
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct PromptMapping {
89    /// Placeholder entries in source-text order.
90    pub entries: Vec<PromptMappingEntry>,
91}
92
93impl PromptMapping {
94    /// Restores all known placeholders in a string.
95    #[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/// One placeholder-to-original-value mapping.
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
115pub struct PromptMappingEntry {
116    /// Placeholder inserted into the sanitized prompt, such as `[EMAIL_1]`.
117    pub placeholder: String,
118    /// Entity type represented by this placeholder.
119    pub entity_type: EntityType,
120    /// Original sensitive value.
121    pub original: String,
122    /// Source prompt start byte offset.
123    pub span_start: usize,
124    /// Source prompt end byte offset.
125    pub span_end: usize,
126    /// Detection confidence.
127    pub confidence: f64,
128    /// Recognizer that produced the original finding.
129    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}