1010import time
1111from typing import Any
1212
13- from fastapi import FastAPI
13+ import regex as regex_module
14+ from fastapi import FastAPI , HTTPException
1415from presidio_analyzer import (
1516 AnalyzerEngine ,
1617 BatchAnalyzerEngine ,
@@ -220,9 +221,11 @@ def _analyze_one(
220221 entities : list [str ] | None ,
221222 score_threshold : float | None ,
222223 return_decision_process : bool = False ,
224+ ad_hoc_recognizers : list [PatternRecognizer ] | None = None ,
223225):
224226 # Regex-only requests reuse a blank NlpArtifacts to skip the spaCy NLP pass;
225- # otherwise analyze() computes artifacts (runs spaCy) as usual.
227+ # otherwise analyze() computes artifacts (runs spaCy) as usual. Custom-pattern
228+ # recognizers are regex-based, so they run fine against the blank artifacts.
226229 nlp_artifacts = (
227230 _BLANK_ARTIFACTS .get (language ) if _regex_only (entities , score_threshold ) else None
228231 )
@@ -233,6 +236,7 @@ def _analyze_one(
233236 score_threshold = score_threshold ,
234237 return_decision_process = return_decision_process ,
235238 nlp_artifacts = nlp_artifacts ,
239+ ad_hoc_recognizers = ad_hoc_recognizers or None ,
236240 )
237241
238242
@@ -241,6 +245,7 @@ def _analyze_many(
241245 language : str ,
242246 entities : list [str ] | None ,
243247 score_threshold : float | None ,
248+ ad_hoc_recognizers : list [PatternRecognizer ] | None = None ,
244249):
245250 """Analyze many texts, skipping the spaCy pass for regex-only requests."""
246251 if _regex_only (entities , score_threshold ):
@@ -252,6 +257,7 @@ def _analyze_many(
252257 entities = entities ,
253258 score_threshold = score_threshold ,
254259 nlp_artifacts = blank ,
260+ ad_hoc_recognizers = ad_hoc_recognizers or None ,
255261 )
256262 for text in texts
257263 ]
@@ -261,33 +267,116 @@ def _analyze_many(
261267 language = language ,
262268 entities = entities or None ,
263269 score_threshold = score_threshold ,
270+ ad_hoc_recognizers = ad_hoc_recognizers or None ,
264271 )
265272 )
266273
267274
268275app = FastAPI (title = "Sim Presidio" , docs_url = None , redoc_url = None )
269276
277+ # Internal entity id assigned to the i-th user-supplied custom pattern. Never
278+ # surfaced: the anonymizer maps it back to the pattern's chosen `replacement`, and
279+ # callers relabel any leftover CUSTOM_<i> span to the pattern's display name.
280+ CUSTOM_ENTITY_PREFIX = "CUSTOM_"
281+
282+
283+ class CustomPattern (BaseModel ):
284+ """A user-supplied regex pattern. Matches are replaced with `replacement`,
285+ wrapped in angle brackets (see `_wrap_token`)."""
286+
287+ regex : str
288+ replacement : str = ""
289+ name : str = ""
290+
291+
292+ def _wrap_token (replacement : str ) -> str :
293+ """Wrap the redaction token in angle brackets so custom matches read like the
294+ built-in Presidio tokens (`<PERSON>`, `<EMAIL_ADDRESS>`). A value the user
295+ already bracketed is left as-is so it never double-wraps to `<<X>>`."""
296+ if len (replacement ) >= 2 and replacement .startswith ("<" ) and replacement .endswith (">" ):
297+ return replacement
298+ return f"<{ replacement } >"
299+
300+
301+ def custom_operators (patterns : list [CustomPattern ] | None ) -> dict [str , dict [str , Any ]]:
302+ """Raw replace-operator per custom pattern, keyed by its internal entity id."""
303+ return {
304+ f"{ CUSTOM_ENTITY_PREFIX } { i } " : {"type" : "replace" , "new_value" : _wrap_token (p .replacement )}
305+ for i , p in enumerate (patterns or [])
306+ }
307+
308+
309+ def build_custom_recognizers (
310+ patterns : list [CustomPattern ] | None , language : str
311+ ) -> tuple [list [PatternRecognizer ], list [str ]]:
312+ """Ad-hoc PatternRecognizers + their entity ids for the given custom patterns.
313+
314+ Each regex is precompiled so a malformed pattern fails fast as a 400 rather
315+ than surfacing later as an opaque analyze-time 500."""
316+ recognizers : list [PatternRecognizer ] = []
317+ entity_ids : list [str ] = []
318+ for i , p in enumerate (patterns or []):
319+ try :
320+ regex_module .compile (p .regex )
321+ except regex_module .error as exc :
322+ raise HTTPException (
323+ status_code = 400 , detail = f"Invalid custom pattern regex: { exc } "
324+ ) from exc
325+ entity = f"{ CUSTOM_ENTITY_PREFIX } { i } "
326+ recognizers .append (
327+ PatternRecognizer (
328+ supported_entity = entity ,
329+ # Score 1.0 so a user's explicit pattern wins any overlap with a
330+ # built-in detector (e.g. spaCy tagging "EMP-123456" as ORGANIZATION
331+ # under detect-all). Presidio resolves overlapping spans by score, so
332+ # the custom replacement — not the built-in token — is applied.
333+ patterns = [Pattern (name = p .name or entity , regex = p .regex , score = 1.0 )],
334+ supported_language = language ,
335+ )
336+ )
337+ entity_ids .append (entity )
338+ return recognizers , entity_ids
339+
340+
341+ def resolve_entities (
342+ req_entities : list [str ] | None , custom_entity_ids : list [str ]
343+ ) -> list [str ] | None :
344+ """Effective entity filter.
345+
346+ `None` means detect-all built-ins (the guardrails "empty selection = detect
347+ everything" convention); the ad-hoc custom recognizers still fire under `None`,
348+ so adding a custom pattern augments detect-all rather than silently disabling
349+ the built-in detectors. An explicit list — including the empty list, which is
350+ the data-retention "only these custom patterns" shape — is used verbatim, with
351+ the custom ids appended."""
352+ if req_entities is None :
353+ return None
354+ return list (req_entities ) + custom_entity_ids
355+
270356
271357class AnalyzeRequest (BaseModel ):
272358 text : str
273359 language : str = "en"
274360 entities : list [str ] | None = None
275361 score_threshold : float | None = None
276362 return_decision_process : bool = False
363+ patterns : list [CustomPattern ] | None = None
277364
278365
279366class AnalyzeBatchRequest (BaseModel ):
280367 texts : list [str ]
281368 language : str = "en"
282369 entities : list [str ] | None = None
283370 score_threshold : float | None = None
371+ patterns : list [CustomPattern ] | None = None
284372
285373
286374class AnonymizeRequest (BaseModel ):
287375 text : str
288376 analyzer_results : list [dict [str , Any ]] = []
289377 anonymizers : dict [str , dict [str , Any ]] | None = None
290378 operators : dict [str , dict [str , Any ]] | None = None
379+ patterns : list [CustomPattern ] | None = None
291380
292381
293382class AnonymizeBatchItem (BaseModel ):
@@ -299,6 +388,7 @@ class AnonymizeBatchRequest(BaseModel):
299388 items : list [AnonymizeBatchItem ] = []
300389 anonymizers : dict [str , dict [str , Any ]] | None = None
301390 operators : dict [str , dict [str , Any ]] | None = None
391+ patterns : list [CustomPattern ] | None = None
302392
303393
304394class RedactRequest (BaseModel ):
@@ -308,6 +398,7 @@ class RedactRequest(BaseModel):
308398 score_threshold : float | None = None
309399 anonymizers : dict [str , dict [str , Any ]] | None = None
310400 operators : dict [str , dict [str , Any ]] | None = None
401+ patterns : list [CustomPattern ] | None = None
311402
312403
313404class RedactBatchRequest (BaseModel ):
@@ -317,6 +408,7 @@ class RedactBatchRequest(BaseModel):
317408 score_threshold : float | None = None
318409 anonymizers : dict [str , dict [str , Any ]] | None = None
319410 operators : dict [str , dict [str , Any ]] | None = None
411+ patterns : list [CustomPattern ] | None = None
320412
321413
322414def build_operators (
@@ -332,6 +424,17 @@ def build_operators(
332424 return operators
333425
334426
427+ def resolve_operators (
428+ anonymizers : dict [str , dict [str , Any ]] | None ,
429+ operators : dict [str , dict [str , Any ]] | None ,
430+ patterns : list [CustomPattern ] | None ,
431+ ) -> dict [str , OperatorConfig ] | None :
432+ """Merge the caller's operators with the per-custom-pattern replace operators."""
433+ raw = dict (anonymizers or operators or {})
434+ raw .update (custom_operators (patterns ))
435+ return build_operators (raw )
436+
437+
335438def run_anonymize (
336439 text : str ,
337440 raw_results : list [dict [str , Any ]],
@@ -366,12 +469,15 @@ def supported_entities(language: str = "en") -> list[str]:
366469@app .post ("/analyze" )
367470def analyze (req : AnalyzeRequest ) -> list [dict [str , Any ]]:
368471 started = time .perf_counter ()
472+ recognizers , custom_ids = build_custom_recognizers (req .patterns , req .language )
473+ entities = resolve_entities (req .entities , custom_ids )
369474 results = _analyze_one (
370475 req .text ,
371476 req .language ,
372- req . entities ,
477+ entities ,
373478 req .score_threshold ,
374479 req .return_decision_process ,
480+ recognizers ,
375481 )
376482 logger .info (
377483 "analyze lang=%s chars=%d entities=%d duration_ms=%.1f" ,
@@ -387,14 +493,16 @@ def analyze(req: AnalyzeRequest) -> list[dict[str, Any]]:
387493def analyze_batch (req : AnalyzeBatchRequest ) -> list [list [dict [str , Any ]]]:
388494 """Analyze many texts in one pass (spaCy nlp.pipe), returning one span list
389495 per input in request order — the batched counterpart to /analyze."""
390- results = _analyze_many (req .texts , req .language , req .entities , req .score_threshold )
496+ recognizers , custom_ids = build_custom_recognizers (req .patterns , req .language )
497+ entities = resolve_entities (req .entities , custom_ids )
498+ results = _analyze_many (req .texts , req .language , entities , req .score_threshold , recognizers )
391499 return [[r .to_dict () for r in per_text ] for per_text in results ]
392500
393501
394502@app .post ("/anonymize" )
395503def anonymize (req : AnonymizeRequest ) -> dict [str , Any ]:
396504 started = time .perf_counter ()
397- operators = build_operators (req .anonymizers or req .operators )
505+ operators = resolve_operators (req .anonymizers , req .operators , req . patterns )
398506 result = run_anonymize (req .text , req .analyzer_results , operators )
399507 logger .info (
400508 "anonymize chars=%d spans=%d duration_ms=%.1f" ,
@@ -422,7 +530,7 @@ def anonymize_batch(req: AnonymizeBatchRequest) -> dict[str, list[str]]:
422530 """Mask many texts in one pass, returning masked text per item in request
423531 order — the batched counterpart to /anonymize. Anonymization is pure string
424532 work (no NLP), so callers should send only items with detected spans."""
425- operators = build_operators (req .anonymizers or req .operators )
533+ operators = resolve_operators (req .anonymizers , req .operators , req . patterns )
426534 return {
427535 "texts" : [
428536 run_anonymize (item .text , item .analyzer_results , operators ).text
@@ -438,8 +546,12 @@ def redact(req: RedactRequest) -> dict[str, str]:
438546 with no detected PII passes through unchanged. The analyzer results feed the
439547 anonymizer directly (no dict round-trip)."""
440548 started = time .perf_counter ()
441- operators = build_operators (req .anonymizers or req .operators )
442- results = _analyze_one (req .text , req .language , req .entities , req .score_threshold )
549+ recognizers , custom_ids = build_custom_recognizers (req .patterns , req .language )
550+ entities = resolve_entities (req .entities , custom_ids )
551+ operators = resolve_operators (req .anonymizers , req .operators , req .patterns )
552+ results = _analyze_one (
553+ req .text , req .language , entities , req .score_threshold , ad_hoc_recognizers = recognizers
554+ )
443555 text = (
444556 req .text
445557 if not results
@@ -466,8 +578,10 @@ def redact_batch(req: RedactBatchRequest) -> dict[str, list[str]]:
466578 the anonymizer directly (no dict round-trip), and anonymization runs only on
467579 texts that actually matched."""
468580 started = time .perf_counter ()
469- operators = build_operators (req .anonymizers or req .operators )
470- analyzed = _analyze_many (req .texts , req .language , req .entities , req .score_threshold )
581+ recognizers , custom_ids = build_custom_recognizers (req .patterns , req .language )
582+ entities = resolve_entities (req .entities , custom_ids )
583+ operators = resolve_operators (req .anonymizers , req .operators , req .patterns )
584+ analyzed = _analyze_many (req .texts , req .language , entities , req .score_threshold , recognizers )
471585 masked : list [str ] = []
472586 total_spans = 0
473587 for text , per_text in zip (req .texts , analyzed ):
@@ -484,8 +598,8 @@ def redact_batch(req: RedactBatchRequest) -> dict[str, list[str]]:
484598 "redact_batch lang=%s texts=%d entities=%s nlp=%s spans=%d duration_ms=%.1f" ,
485599 req .language ,
486600 len (req .texts ),
487- len (req . entities ) if req . entities else "all" ,
488- "skip" if _regex_only (req . entities , req .score_threshold ) else "full" ,
601+ len (entities ) if entities else "all" ,
602+ "skip" if _regex_only (entities , req .score_threshold ) else "full" ,
489603 total_spans ,
490604 (time .perf_counter () - started ) * 1000 ,
491605 )
0 commit comments