prompt-sanitizer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptSanitizer
4
+ module Engines
5
+ # NER Engine — Layer 2 of prompt-sanitizer (SMART / FULL mode only).
6
+ #
7
+ # Detects context-dependent PII that regex cannot catch: person names,
8
+ # organisations, locations, and miscellaneous named entities.
9
+ #
10
+ # Two backends are supported:
11
+ #
12
+ # :informers — uses the `informers` gem with Xenova/distilbert-NER or
13
+ # Xenova/bert-base-NER (ONNX int8). F1 92.17. ~25–50 ms.
14
+ # Model auto-downloaded to ~/.cache/huggingface/ on first use.
15
+ # Recommended default.
16
+ #
17
+ # :mitie — uses the `mitie` gem with the MITIE C++ NER model.
18
+ # F1 88.10. ~2 ms. Requires separate model download (~600 MB).
19
+ # Use when sub-5 ms latency is required.
20
+ #
21
+ # Both backends are optional runtime dependencies. When neither gem is
22
+ # installed, NEREngine#available? returns false and #detect returns [].
23
+ # The Sanitizer falls back to FAST mode silently in this case.
24
+ #
25
+ # Thread safety: both ONNX Runtime sessions and MITIE models are immutable
26
+ # after loading — safe to share across Puma threads.
27
+ class NEREngine
28
+ # Maximum number of characters per chunk when splitting long prompts.
29
+ # distilbert / bert-base have a 512 subword-token limit; ~300 words
30
+ # ≈ 1,800 characters is a safe conservative ceiling.
31
+ CHUNK_SIZE = 1_800
32
+ CHUNK_OVERLAP = 200 # overlap between chunks to avoid edge-case misses
33
+
34
+ # BIO tag → EntityType mapping (CoNLL-2003 schema)
35
+ TAG_MAP = {
36
+ "PER" => EntityType::PERSON,
37
+ "ORG" => EntityType::ORGANIZATION,
38
+ "LOC" => EntityType::LOCATION,
39
+ "MISC" => EntityType::MISC,
40
+ }.freeze
41
+
42
+ # ── Construction ────────────────────────────────────────────────────────
43
+
44
+ # @param backend [Symbol] :informers (default) or :mitie
45
+ # @param model [String] "distilbert" (default) or "bert-base" for
46
+ # the informers backend; path to ner_model.dat
47
+ # for the mitie backend.
48
+ def initialize(backend: :informers, model: "distilbert")
49
+ @backend = backend
50
+ @model = model
51
+ @pipeline = nil
52
+ @mutex = Mutex.new
53
+ _load_backend
54
+ end
55
+
56
+ # Returns true when the chosen backend gem is installed and the model
57
+ # is ready to use.
58
+ def available?
59
+ !@pipeline.nil?
60
+ end
61
+
62
+ # Detect named entities in +text+ and return Array<DetectedEntity>.
63
+ # Returns [] immediately when the backend is unavailable.
64
+ #
65
+ # Long texts are automatically chunked and results are merged.
66
+ def detect(text)
67
+ return [] unless available?
68
+
69
+ safe_text = text.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
70
+ return [] if safe_text.strip.empty?
71
+
72
+ if safe_text.length > CHUNK_SIZE
73
+ _detect_chunked(safe_text)
74
+ else
75
+ _detect_single(safe_text)
76
+ end
77
+ rescue => e
78
+ # Never let NER failures break the sanitizer — degrade gracefully.
79
+ warn "[PromptSanitizer] NER error (#{@backend}): #{e.message}"
80
+ []
81
+ end
82
+
83
+ # ── Private ─────────────────────────────────────────────────────────────
84
+
85
+ private
86
+
87
+ def _load_backend
88
+ case @backend
89
+ when :informers then _load_informers
90
+ when :mitie then _load_mitie
91
+ else
92
+ raise ConfigurationError, "Unknown NER backend: #{@backend.inspect}. Use :informers or :mitie."
93
+ end
94
+ end
95
+
96
+ # ── informers backend ────────────────────────────────────────────────────
97
+
98
+ def _load_informers
99
+ require "informers"
100
+
101
+ model_id = case @model
102
+ when "distilbert" then "Xenova/distilbert-NER"
103
+ when "bert-base" then "Xenova/bert-base-NER"
104
+ else @model # allow fully-qualified HuggingFace model IDs
105
+ end
106
+
107
+ # Load once and memoize — thread-safe after initialization.
108
+ @pipeline = Informers.pipeline("ner", model_id, dtype: "int8")
109
+ @backend_type = :informers
110
+ rescue LoadError
111
+ # informers gem not installed — NER silently unavailable.
112
+ @pipeline = nil
113
+ rescue => e
114
+ warn "[PromptSanitizer] Failed to load informers NER model: #{e.message}"
115
+ @pipeline = nil
116
+ end
117
+
118
+ # ── mitie backend ────────────────────────────────────────────────────────
119
+
120
+ def _load_mitie
121
+ require "mitie"
122
+
123
+ model_path = @model == "distilbert" || @model == "bert-base" ? nil : @model
124
+ model_path ||= ENV.fetch("MITIE_MODEL_PATH", "ner_model.dat")
125
+
126
+ unless File.exist?(model_path)
127
+ warn "[PromptSanitizer] MITIE model not found at #{model_path}. " \
128
+ "Download from https://github.com/mit-nlp/MITIE/releases"
129
+ @pipeline = nil
130
+ return
131
+ end
132
+
133
+ @pipeline = Mitie::NER.new(model_path)
134
+ @backend_type = :mitie
135
+ rescue LoadError
136
+ @pipeline = nil
137
+ rescue => e
138
+ warn "[PromptSanitizer] Failed to load MITIE NER model: #{e.message}"
139
+ @pipeline = nil
140
+ end
141
+
142
+ # ── Detection ────────────────────────────────────────────────────────────
143
+
144
+ def _detect_single(text)
145
+ case @backend_type
146
+ when :informers then _informers_detect(text, offset: 0)
147
+ when :mitie then _mitie_detect(text, offset: 0)
148
+ else []
149
+ end
150
+ end
151
+
152
+ # Split long text into overlapping chunks, detect in each, then merge.
153
+ # Entities whose start_pos falls in the overlap zone of a later chunk
154
+ # are deduplicated by (original, start_pos) pair.
155
+ def _detect_chunked(text)
156
+ entities = []
157
+ seen = {}
158
+ pos = 0
159
+
160
+ while pos < text.length
161
+ chunk = text[pos, CHUNK_SIZE]
162
+ chunk_hits = _detect_single(chunk).map do |e|
163
+ DetectedEntity.new(
164
+ entity_type: e.entity_type,
165
+ original: e.original,
166
+ replacement: nil,
167
+ start_pos: e.start_pos + pos,
168
+ end_pos: e.end_pos + pos,
169
+ confidence: e.confidence,
170
+ layer: :ner
171
+ )
172
+ end
173
+
174
+ chunk_hits.each do |e|
175
+ key = "#{e.original}:#{e.start_pos}"
176
+ next if seen[key]
177
+
178
+ seen[key] = true
179
+ entities << e
180
+ end
181
+
182
+ pos += CHUNK_SIZE - CHUNK_OVERLAP
183
+ end
184
+
185
+ entities
186
+ end
187
+
188
+ # ── informers result parsing ─────────────────────────────────────────────
189
+
190
+ # informers returns BIO-tagged word pieces; merge consecutive I- tags
191
+ # back into a single span.
192
+ def _informers_detect(text, offset:)
193
+ raw = @pipeline.(text)
194
+ return [] if raw.nil? || raw.empty?
195
+
196
+ entities = []
197
+ current = nil
198
+
199
+ Array(raw).each do |token|
200
+ tag_raw = token[:entity] || token["entity"] || ""
201
+ word = token[:word] || token["word"] || ""
202
+ score = (token[:score] || token["score"] || 0.0).to_f
203
+ t_start = (token[:start] || token["start"] || 0).to_i
204
+ t_end = (token[:end] || token["end"] || 0).to_i
205
+
206
+ # Parse BIO prefix and base tag
207
+ bio, tag = tag_raw.split("-", 2)
208
+ entity_type = TAG_MAP[tag]
209
+ next if entity_type.nil?
210
+
211
+ if bio == "B" || (bio == "I" && current.nil?)
212
+ # Flush previous entity
213
+ entities << _build_entity(current, text) if current
214
+ current = { type: entity_type, start: t_start, end: t_end, score: score, tokens: [word] }
215
+
216
+ elsif bio == "I" && current && current[:type] == entity_type
217
+ # Continue current entity — extend span
218
+ current[:end] = t_end
219
+ current[:score] = [current[:score], score].min # conservative: use lowest
220
+ current[:tokens] << word
221
+
222
+ else
223
+ # Tag changed mid-sequence — flush and start fresh
224
+ entities << _build_entity(current, text) if current
225
+ current = nil
226
+ end
227
+ end
228
+
229
+ entities << _build_entity(current, text) if current
230
+ entities.compact
231
+ end
232
+
233
+ def _build_entity(data, text)
234
+ return nil if data.nil?
235
+
236
+ raw_value = text[data[:start]...data[:end]]
237
+ return nil if raw_value.nil? || raw_value.strip.empty?
238
+
239
+ DetectedEntity.new(
240
+ entity_type: data[:type],
241
+ original: raw_value.strip,
242
+ replacement: nil,
243
+ start_pos: data[:start],
244
+ end_pos: data[:end],
245
+ confidence: data[:score],
246
+ layer: :ner
247
+ )
248
+ end
249
+
250
+ # ── MITIE result parsing ─────────────────────────────────────────────────
251
+
252
+ def _mitie_detect(text, offset:)
253
+ doc = @pipeline.doc(text)
254
+ doc.entities.filter_map do |entity|
255
+ tag = entity[:tag]&.upcase
256
+ entity_type = TAG_MAP[tag]
257
+ next unless entity_type
258
+
259
+ value = entity[:text]
260
+ next if value.nil? || value.strip.empty?
261
+
262
+ # MITIE provides character offset via :offset
263
+ char_start = entity[:offset] || text.index(value) || 0
264
+ char_end = char_start + value.length
265
+
266
+ DetectedEntity.new(
267
+ entity_type: entity_type,
268
+ original: value.strip,
269
+ replacement: nil,
270
+ start_pos: char_start,
271
+ end_pos: char_end,
272
+ confidence: (entity[:score] || 0.80).to_f.clamp(0.0, 1.0),
273
+ layer: :ner
274
+ )
275
+ end
276
+ end
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptSanitizer
4
+ module Engines
5
+ # Regex Engine — Layer 1 of prompt-sanitizer.
6
+ #
7
+ # Detects structured PII (email, phone, SSN, credit cards, IBANs, IPs,
8
+ # crypto addresses, MAC addresses, URLs, passport numbers, driving licences,
9
+ # and date patterns) using regular expressions with optional checksum
10
+ # validation (Luhn for credit cards, IBAN mod-97).
11
+ #
12
+ # All patterns run on every sanitize() call regardless of Mode.
13
+ class RegexEngine
14
+ Pattern = Struct.new(:entity_type, :regex, :confidence, :validator, keyword_init: true)
15
+
16
+ # ── Validators ──────────────────────────────────────────────────────────
17
+
18
+ # Luhn algorithm — validates credit/debit card numbers.
19
+ def self.luhn_valid?(card)
20
+ digits = card.gsub(/\D/, "").chars.map(&:to_i)
21
+ return false if digits.size < 13
22
+
23
+ total = digits.reverse.each_with_index.sum do |d, i|
24
+ i.odd? ? [d * 2 - 9, d * 2].min + (d * 2 >= 10 ? 0 : 0) : d
25
+ # Standard Luhn: double every second digit from the right
26
+ if i.odd?
27
+ doubled = d * 2
28
+ doubled > 9 ? doubled - 9 : doubled
29
+ else
30
+ d
31
+ end
32
+ end
33
+ total % 10 == 0
34
+ end
35
+
36
+ # IBAN mod-97 validation.
37
+ def self.iban_valid?(iban)
38
+ raw = iban.gsub(/[\s\-]/, "").upcase
39
+ return false unless raw.length.between?(15, 34)
40
+
41
+ rearranged = raw[4..] + raw[0..3]
42
+ numeric = rearranged.chars.map { |c| c =~ /[A-Z]/ ? (c.ord - 55).to_s : c }.join
43
+ numeric.to_i % 97 == 1
44
+ rescue ArgumentError
45
+ false
46
+ end
47
+
48
+ # ── Pattern registry ─────────────────────────────────────────────────────
49
+
50
+ PATTERNS = [
51
+ # ── Email ──────────────────────────────────────────────────────────────
52
+ Pattern.new(
53
+ entity_type: EntityType::EMAIL,
54
+ regex: /(?<![a-zA-Z0-9._%+\-])[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}(?![a-zA-Z0-9._%+\-@])/i,
55
+ confidence: 0.99
56
+ ),
57
+
58
+ # ── US phone ───────────────────────────────────────────────────────────
59
+ Pattern.new(
60
+ entity_type: EntityType::PHONE,
61
+ regex: /(?<!\d)(?:\+?1[\s.\-]?)?(?:\([2-9]\d{2}\)|[2-9]\d{2})[\s.\-]?\d{3}[\s.\-]?\d{4}(?!\d)/,
62
+ confidence: 0.85
63
+ ),
64
+
65
+ # ── International phone — compact E.164 e.g. +447946123456 ────────────
66
+ Pattern.new(
67
+ entity_type: EntityType::PHONE,
68
+ regex: /(?<!\d)\+[1-9]\d{6,14}(?!\d)/,
69
+ confidence: 0.80
70
+ ),
71
+
72
+ # ── International phone — spaced/dashed e.g. +44 20 7946 0958 ─────────
73
+ Pattern.new(
74
+ entity_type: EntityType::PHONE,
75
+ regex: /(?<!\d)\+[1-9]\d{0,3}(?:[\s.\-]\d{2,4}){2,4}(?!\d)/,
76
+ confidence: 0.78
77
+ ),
78
+
79
+ # ── US SSN ─────────────────────────────────────────────────────────────
80
+ Pattern.new(
81
+ entity_type: EntityType::SSN,
82
+ regex: /(?<!\d)(?!000|666|9\d{2})\d{3}[\s\-](?!00)\d{2}[\s\-](?!0000)\d{4}(?!\d)/,
83
+ confidence: 0.95
84
+ ),
85
+
86
+ # ── Credit / debit card (Luhn-validated) ──────────────────────────────
87
+ Pattern.new(
88
+ entity_type: EntityType::CREDIT_CARD,
89
+ regex: /(?<!\d)(?:4[0-9]{3}|5[1-5][0-9]{2}|3[47][0-9]{2}|3(?:0[0-5]|[68][0-9])[0-9]|6(?:011|5[0-9]{2})|(?:2131|1800|35\d{3}))[\s\-]?(?:\d{4}[\s\-]?){2}\d{1,4}(?!\d)/,
90
+ confidence: 0.95,
91
+ validator: ->(m) { luhn_valid?(m) }
92
+ ),
93
+
94
+ # ── IBAN (mod-97 validated) ────────────────────────────────────────────
95
+ Pattern.new(
96
+ entity_type: EntityType::IBAN,
97
+ regex: /\b[A-Z]{2}\d{2}(?:\s?[A-Z0-9]{4}){2,7}\s?[A-Z0-9]{1,4}\b/i,
98
+ confidence: 0.92,
99
+ validator: ->(m) { iban_valid?(m) }
100
+ ),
101
+
102
+ # ── IPv4 ───────────────────────────────────────────────────────────────
103
+ Pattern.new(
104
+ entity_type: EntityType::IP_ADDRESS,
105
+ regex: /(?<!\d)(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)(?!\d)/,
106
+ confidence: 0.90
107
+ ),
108
+
109
+ # ── IPv6 (full and compressed forms) ──────────────────────────────────
110
+ Pattern.new(
111
+ entity_type: EntityType::IP_ADDRESS,
112
+ regex: /(?<![:\w])(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}|[0-9a-fA-F]{1,4}::(?:[0-9a-fA-F]{1,4}:){0,4}[0-9a-fA-F]{1,4})(?![:\w])/i,
113
+ confidence: 0.90
114
+ ),
115
+
116
+ # ── MAC address ────────────────────────────────────────────────────────
117
+ Pattern.new(
118
+ entity_type: EntityType::MAC_ADDRESS,
119
+ regex: /(?<![:\w])(?:[0-9a-fA-F]{2}[:\-]){5}[0-9a-fA-F]{2}(?![:\w])/i,
120
+ confidence: 0.90
121
+ ),
122
+
123
+ # ── URL (http/https) ───────────────────────────────────────────────────
124
+ Pattern.new(
125
+ entity_type: EntityType::URL,
126
+ regex: /https?:\/\/(?:[a-zA-Z0-9\-._~:\/?#\[\]@!$&'()*+,;=%]|(?:%[0-9a-fA-F]{2}))+/i,
127
+ confidence: 0.85
128
+ ),
129
+
130
+ # ── Bitcoin address (P2PKH, P2SH, Bech32) ─────────────────────────────
131
+ Pattern.new(
132
+ entity_type: EntityType::CRYPTO_ADDRESS,
133
+ regex: /(?<![a-zA-Z0-9])(?:[13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{6,87})(?![a-zA-Z0-9])/,
134
+ confidence: 0.88
135
+ ),
136
+
137
+ # ── Ethereum address ───────────────────────────────────────────────────
138
+ Pattern.new(
139
+ entity_type: EntityType::CRYPTO_ADDRESS,
140
+ regex: /(?<![a-fA-F0-9])0x[0-9a-fA-F]{40}(?![0-9a-fA-F])/,
141
+ confidence: 0.92
142
+ ),
143
+
144
+ # ── US Passport ────────────────────────────────────────────────────────
145
+ Pattern.new(
146
+ entity_type: EntityType::PASSPORT,
147
+ regex: /(?<![A-Z0-9])[A-Z]{1,2}\d{7,9}(?![A-Z0-9])/,
148
+ confidence: 0.72
149
+ ),
150
+
151
+ # ── US ZIP code ────────────────────────────────────────────────────────
152
+ Pattern.new(
153
+ entity_type: EntityType::ZIP_CODE,
154
+ regex: /(?<!\d)\d{5}(?:-\d{4})?(?!\d)/,
155
+ confidence: 0.55
156
+ ),
157
+
158
+ # ── Date patterns (DD/MM/YYYY, YYYY-MM-DD, Month DD YYYY, etc.) ────────
159
+ Pattern.new(
160
+ entity_type: EntityType::DATE,
161
+ regex: /(?<!\d)(?:\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{2,4}|\d{4}[\/\-\.]\d{1,2}[\/\-\.]\d{1,2}|(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}|\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{4})(?!\d)/i,
162
+ confidence: 0.75
163
+ ),
164
+ ].freeze
165
+
166
+ # ── Instance ─────────────────────────────────────────────────────────────
167
+
168
+ def initialize
169
+ @patterns = PATTERNS.dup
170
+ end
171
+
172
+ # Register a custom regex pattern at runtime.
173
+ #
174
+ # engine.add_pattern(:custom, /\bACME-\d{6}\b/, confidence: 0.90)
175
+ # engine.add_pattern(:custom, "ACME-\\d{6}", confidence: 0.90)
176
+ def add_pattern(entity_type, regex, confidence: 0.80, validator: nil)
177
+ regex = Regexp.new(regex) if regex.is_a?(String)
178
+ @patterns << Pattern.new(
179
+ entity_type: entity_type,
180
+ regex: regex,
181
+ confidence: confidence,
182
+ validator: validator
183
+ )
184
+ end
185
+
186
+ # Run all patterns against +text+ and return an Array of DetectedEntity.
187
+ # Overlapping matches from different patterns are kept — deduplication
188
+ # happens in the Sanitizer, which has the full multi-engine view.
189
+ def detect(text)
190
+ safe_text = text.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
191
+ entities = []
192
+
193
+ @patterns.each do |pat|
194
+ safe_text.scan(pat.regex) do
195
+ m = Regexp.last_match
196
+ value = m[0]
197
+
198
+ next if pat.validator && !self.class.instance_exec(value, &pat.validator)
199
+
200
+ entities << DetectedEntity.new(
201
+ entity_type: pat.entity_type,
202
+ original: value,
203
+ replacement: nil,
204
+ start_pos: m.begin(0),
205
+ end_pos: m.end(0),
206
+ confidence: pat.confidence,
207
+ layer: :regex
208
+ )
209
+ end
210
+ end
211
+
212
+ entities
213
+ end
214
+ end
215
+ end
216
+ end