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,97 @@ 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+
286+ regex : str
287+ replacement : str = ""
288+ name : str = ""
289+
290+
291+ def custom_operators (patterns : list [CustomPattern ] | None ) -> dict [str , dict [str , Any ]]:
292+ """Raw replace-operator per custom pattern, keyed by its internal entity id."""
293+ return {
294+ f"{ CUSTOM_ENTITY_PREFIX } { i } " : {"type" : "replace" , "new_value" : p .replacement }
295+ for i , p in enumerate (patterns or [])
296+ }
297+
298+
299+ def build_custom_recognizers (
300+ patterns : list [CustomPattern ] | None , language : str
301+ ) -> tuple [list [PatternRecognizer ], list [str ]]:
302+ """Ad-hoc PatternRecognizers + their entity ids for the given custom patterns.
303+
304+ Each regex is precompiled so a malformed pattern fails fast as a 400 rather
305+ than surfacing later as an opaque analyze-time 500."""
306+ recognizers : list [PatternRecognizer ] = []
307+ entity_ids : list [str ] = []
308+ for i , p in enumerate (patterns or []):
309+ try :
310+ regex_module .compile (p .regex )
311+ except regex_module .error as exc :
312+ raise HTTPException (
313+ status_code = 400 , detail = f"Invalid custom pattern regex: { exc } "
314+ ) from exc
315+ entity = f"{ CUSTOM_ENTITY_PREFIX } { i } "
316+ recognizers .append (
317+ PatternRecognizer (
318+ supported_entity = entity ,
319+ patterns = [Pattern (name = p .name or entity , regex = p .regex , score = 0.5 )],
320+ supported_language = language ,
321+ )
322+ )
323+ entity_ids .append (entity )
324+ return recognizers , entity_ids
325+
326+
327+ def resolve_entities (
328+ req_entities : list [str ] | None , custom_entity_ids : list [str ]
329+ ) -> list [str ] | None :
330+ """Effective entity filter: the requested built-ins plus the custom entity ids.
331+ `None` (detect all built-ins) is preserved only when neither is present, so a
332+ custom-only request never widens into detecting every built-in entity."""
333+ if req_entities is None and not custom_entity_ids :
334+ return None
335+ return list (req_entities or []) + custom_entity_ids
336+
270337
271338class AnalyzeRequest (BaseModel ):
272339 text : str
273340 language : str = "en"
274341 entities : list [str ] | None = None
275342 score_threshold : float | None = None
276343 return_decision_process : bool = False
344+ patterns : list [CustomPattern ] | None = None
277345
278346
279347class AnalyzeBatchRequest (BaseModel ):
280348 texts : list [str ]
281349 language : str = "en"
282350 entities : list [str ] | None = None
283351 score_threshold : float | None = None
352+ patterns : list [CustomPattern ] | None = None
284353
285354
286355class AnonymizeRequest (BaseModel ):
287356 text : str
288357 analyzer_results : list [dict [str , Any ]] = []
289358 anonymizers : dict [str , dict [str , Any ]] | None = None
290359 operators : dict [str , dict [str , Any ]] | None = None
360+ patterns : list [CustomPattern ] | None = None
291361
292362
293363class AnonymizeBatchItem (BaseModel ):
@@ -299,6 +369,7 @@ class AnonymizeBatchRequest(BaseModel):
299369 items : list [AnonymizeBatchItem ] = []
300370 anonymizers : dict [str , dict [str , Any ]] | None = None
301371 operators : dict [str , dict [str , Any ]] | None = None
372+ patterns : list [CustomPattern ] | None = None
302373
303374
304375class RedactRequest (BaseModel ):
@@ -308,6 +379,7 @@ class RedactRequest(BaseModel):
308379 score_threshold : float | None = None
309380 anonymizers : dict [str , dict [str , Any ]] | None = None
310381 operators : dict [str , dict [str , Any ]] | None = None
382+ patterns : list [CustomPattern ] | None = None
311383
312384
313385class RedactBatchRequest (BaseModel ):
@@ -317,6 +389,7 @@ class RedactBatchRequest(BaseModel):
317389 score_threshold : float | None = None
318390 anonymizers : dict [str , dict [str , Any ]] | None = None
319391 operators : dict [str , dict [str , Any ]] | None = None
392+ patterns : list [CustomPattern ] | None = None
320393
321394
322395def build_operators (
@@ -332,6 +405,17 @@ def build_operators(
332405 return operators
333406
334407
408+ def resolve_operators (
409+ anonymizers : dict [str , dict [str , Any ]] | None ,
410+ operators : dict [str , dict [str , Any ]] | None ,
411+ patterns : list [CustomPattern ] | None ,
412+ ) -> dict [str , OperatorConfig ] | None :
413+ """Merge the caller's operators with the per-custom-pattern replace operators."""
414+ raw = dict (anonymizers or operators or {})
415+ raw .update (custom_operators (patterns ))
416+ return build_operators (raw )
417+
418+
335419def run_anonymize (
336420 text : str ,
337421 raw_results : list [dict [str , Any ]],
@@ -366,12 +450,15 @@ def supported_entities(language: str = "en") -> list[str]:
366450@app .post ("/analyze" )
367451def analyze (req : AnalyzeRequest ) -> list [dict [str , Any ]]:
368452 started = time .perf_counter ()
453+ recognizers , custom_ids = build_custom_recognizers (req .patterns , req .language )
454+ entities = resolve_entities (req .entities , custom_ids )
369455 results = _analyze_one (
370456 req .text ,
371457 req .language ,
372- req . entities ,
458+ entities ,
373459 req .score_threshold ,
374460 req .return_decision_process ,
461+ recognizers ,
375462 )
376463 logger .info (
377464 "analyze lang=%s chars=%d entities=%d duration_ms=%.1f" ,
@@ -387,14 +474,16 @@ def analyze(req: AnalyzeRequest) -> list[dict[str, Any]]:
387474def analyze_batch (req : AnalyzeBatchRequest ) -> list [list [dict [str , Any ]]]:
388475 """Analyze many texts in one pass (spaCy nlp.pipe), returning one span list
389476 per input in request order — the batched counterpart to /analyze."""
390- results = _analyze_many (req .texts , req .language , req .entities , req .score_threshold )
477+ recognizers , custom_ids = build_custom_recognizers (req .patterns , req .language )
478+ entities = resolve_entities (req .entities , custom_ids )
479+ results = _analyze_many (req .texts , req .language , entities , req .score_threshold , recognizers )
391480 return [[r .to_dict () for r in per_text ] for per_text in results ]
392481
393482
394483@app .post ("/anonymize" )
395484def anonymize (req : AnonymizeRequest ) -> dict [str , Any ]:
396485 started = time .perf_counter ()
397- operators = build_operators (req .anonymizers or req .operators )
486+ operators = resolve_operators (req .anonymizers , req .operators , req . patterns )
398487 result = run_anonymize (req .text , req .analyzer_results , operators )
399488 logger .info (
400489 "anonymize chars=%d spans=%d duration_ms=%.1f" ,
@@ -422,7 +511,7 @@ def anonymize_batch(req: AnonymizeBatchRequest) -> dict[str, list[str]]:
422511 """Mask many texts in one pass, returning masked text per item in request
423512 order — the batched counterpart to /anonymize. Anonymization is pure string
424513 work (no NLP), so callers should send only items with detected spans."""
425- operators = build_operators (req .anonymizers or req .operators )
514+ operators = resolve_operators (req .anonymizers , req .operators , req . patterns )
426515 return {
427516 "texts" : [
428517 run_anonymize (item .text , item .analyzer_results , operators ).text
@@ -438,8 +527,12 @@ def redact(req: RedactRequest) -> dict[str, str]:
438527 with no detected PII passes through unchanged. The analyzer results feed the
439528 anonymizer directly (no dict round-trip)."""
440529 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 )
530+ recognizers , custom_ids = build_custom_recognizers (req .patterns , req .language )
531+ entities = resolve_entities (req .entities , custom_ids )
532+ operators = resolve_operators (req .anonymizers , req .operators , req .patterns )
533+ results = _analyze_one (
534+ req .text , req .language , entities , req .score_threshold , ad_hoc_recognizers = recognizers
535+ )
443536 text = (
444537 req .text
445538 if not results
@@ -466,8 +559,10 @@ def redact_batch(req: RedactBatchRequest) -> dict[str, list[str]]:
466559 the anonymizer directly (no dict round-trip), and anonymization runs only on
467560 texts that actually matched."""
468561 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 )
562+ recognizers , custom_ids = build_custom_recognizers (req .patterns , req .language )
563+ entities = resolve_entities (req .entities , custom_ids )
564+ operators = resolve_operators (req .anonymizers , req .operators , req .patterns )
565+ analyzed = _analyze_many (req .texts , req .language , entities , req .score_threshold , recognizers )
471566 masked : list [str ] = []
472567 total_spans = 0
473568 for text , per_text in zip (req .texts , analyzed ):
@@ -484,8 +579,8 @@ def redact_batch(req: RedactBatchRequest) -> dict[str, list[str]]:
484579 "redact_batch lang=%s texts=%d entities=%s nlp=%s spans=%d duration_ms=%.1f" ,
485580 req .language ,
486581 len (req .texts ),
487- len (req . entities ) if req . entities else "all" ,
488- "skip" if _regex_only (req . entities , req .score_threshold ) else "full" ,
582+ len (entities ) if entities else "all" ,
583+ "skip" if _regex_only (entities , req .score_threshold ) else "full" ,
489584 total_spans ,
490585 (time .perf_counter () - started ) * 1000 ,
491586 )
0 commit comments