Coverage for presidio_analyzer / analyzer_engine.py: 97%

136 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-03-29 09:03 +0000

1import json 

2import logging 

3import os 

4from collections import Counter 

5from typing import List, Optional 

6 

7import regex as re 

8 

9from presidio_analyzer import ( 

10 EntityRecognizer, 

11 RecognizerResult, 

12) 

13from presidio_analyzer.app_tracer import AppTracer 

14from presidio_analyzer.context_aware_enhancers import ( 

15 ContextAwareEnhancer, 

16 LemmaContextAwareEnhancer, 

17) 

18from presidio_analyzer.nlp_engine import NlpArtifacts, NlpEngine, NlpEngineProvider 

19from presidio_analyzer.recognizer_registry import ( 

20 RecognizerRegistry, 

21 RecognizerRegistryProvider, 

22) 

23 

24logger = logging.getLogger("presidio-analyzer") 

25 

26REGEX_TIMEOUT_SECONDS = int(os.environ.get("REGEX_TIMEOUT_SECONDS", 60)) 

27 

28class AnalyzerEngine: 

29 """ 

30 Entry point for Presidio Analyzer. 

31 

32 Orchestrating the detection of PII entities and all related logic. 

33 

34 :param registry: instance of type RecognizerRegistry 

35 :param nlp_engine: instance of type NlpEngine 

36 (for example SpacyNlpEngine) 

37 :param app_tracer: instance of type AppTracer, used to trace the logic 

38 used during each request for interpretability reasons. 

39 :param log_decision_process: bool, 

40 defines whether the decision process within the analyzer should be logged or not. 

41 :param default_score_threshold: Minimum confidence value 

42 for detected entities to be returned 

43 :param supported_languages: List of possible languages this engine could be run on. 

44 Used for loading the right NLP models and recognizers for these languages. 

45 :param context_aware_enhancer: instance of type ContextAwareEnhancer for enhancing 

46 confidence score based on context words, (LemmaContextAwareEnhancer will be created 

47 by default if None passed) 

48 """ 

49 

50 def __init__( 

51 self, 

52 registry: RecognizerRegistry = None, 

53 nlp_engine: NlpEngine = None, 

54 app_tracer: AppTracer = None, 

55 log_decision_process: bool = False, 

56 default_score_threshold: float = 0, 

57 supported_languages: List[str] = None, 

58 context_aware_enhancer: Optional[ContextAwareEnhancer] = None, 

59 ): 

60 if not supported_languages: 

61 supported_languages = ["en"] 

62 

63 if not nlp_engine: 

64 logger.info("nlp_engine not provided, creating default.") 

65 provider = NlpEngineProvider() 

66 nlp_engine = provider.create_engine() 

67 

68 if not app_tracer: 

69 app_tracer = AppTracer() 

70 self.app_tracer = app_tracer 

71 

72 self.supported_languages = supported_languages 

73 

74 self.nlp_engine = nlp_engine 

75 if not self.nlp_engine.is_loaded(): 

76 self.nlp_engine.load() 

77 

78 if not registry: 

79 logger.info("registry not provided, creating default.") 

80 provider = RecognizerRegistryProvider( 

81 registry_configuration={"supported_languages": self.supported_languages} 

82 ) 

83 registry = provider.create_recognizer_registry() 

84 registry.add_nlp_recognizer(nlp_engine=self.nlp_engine) 

85 else: 

86 if Counter(registry.supported_languages) != Counter( 

87 self.supported_languages 

88 ): 

89 raise ValueError( 

90 f"Misconfigured engine, supported languages have to be consistent" 

91 f"registry.supported_languages: {registry.supported_languages}, " 

92 f"analyzer_engine.supported_languages: {self.supported_languages}" 

93 ) 

94 

95 # added to support the previous interface 

96 if not registry.recognizers: 

97 registry.load_predefined_recognizers( 

98 nlp_engine=self.nlp_engine, languages=self.supported_languages 

99 ) 

100 

101 self.registry = registry 

102 

103 self.log_decision_process = log_decision_process 

104 self.default_score_threshold = default_score_threshold 

105 

106 if not context_aware_enhancer: 

107 logger.debug( 

108 "context aware enhancer not provided, creating default" 

109 + " lemma based enhancer." 

110 ) 

111 context_aware_enhancer = LemmaContextAwareEnhancer() 

112 

113 self.context_aware_enhancer = context_aware_enhancer 

114 

115 def get_recognizers(self, language: Optional[str] = None) -> List[EntityRecognizer]: 

116 """ 

117 Return a list of PII recognizers currently loaded. 

118 

119 :param language: Return the recognizers supporting a given language. 

120 :return: List of [Recognizer] as a RecognizersAllResponse 

121 """ 

122 if not language: 

123 languages = self.supported_languages 

124 else: 

125 languages = [language] 

126 

127 recognizers = [] 

128 for language in languages: 

129 logger.info(f"Fetching all recognizers for language {language}") 

130 recognizers.extend( 

131 self.registry.get_recognizers(language=language, all_fields=True) 

132 ) 

133 

134 return list(set(recognizers)) 

135 

136 def get_supported_entities(self, language: Optional[str] = None) -> List[str]: 

137 """ 

138 Return a list of the entities that can be detected. 

139 

140 :param language: Return only entities supported in a specific language. 

141 :return: List of entity names 

142 """ 

143 recognizers = self.get_recognizers(language=language) 

144 supported_entities = [] 

145 for recognizer in recognizers: 

146 supported_entities.extend(recognizer.get_supported_entities()) 

147 

148 return list(set(supported_entities)) 

149 

150 def analyze( 

151 self, 

152 text: str, 

153 language: str, 

154 entities: Optional[List[str]] = None, 

155 correlation_id: Optional[str] = None, 

156 score_threshold: Optional[float] = None, 

157 return_decision_process: Optional[bool] = False, 

158 ad_hoc_recognizers: Optional[List[EntityRecognizer]] = None, 

159 context: Optional[List[str]] = None, 

160 allow_list: Optional[List[str]] = None, 

161 allow_list_match: Optional[str] = "exact", 

162 regex_flags: Optional[int] = re.DOTALL | re.MULTILINE | re.IGNORECASE, 

163 nlp_artifacts: Optional[NlpArtifacts] = None, 

164 ) -> List[RecognizerResult]: 

165 """ 

166 Find PII entities in text using different PII recognizers for a given language. 

167 

168 :param text: the text to analyze 

169 :param language: the language of the text 

170 :param entities: List of PII entities that should be looked for in the text. 

171 If entities=None then all entities are looked for. 

172 :param correlation_id: cross call ID for this request 

173 :param score_threshold: A minimum value for which 

174 to return an identified entity 

175 :param return_decision_process: Whether the analysis decision process steps 

176 returned in the response. 

177 :param ad_hoc_recognizers: List of recognizers which will be used only 

178 for this specific request. 

179 :param context: List of context words to enhance confidence score if matched 

180 with the recognized entity's recognizer context 

181 :param allow_list: List of words that the user defines as being allowed to keep 

182 in the text 

183 :param allow_list_match: How the allow_list should be interpreted; either as "exact" or as "regex". 

184 - If `regex`, results which match with any regex condition in the allow_list would be allowed and not be returned as potential PII. 

185 - if `exact`, results which exactly match any value in the allow_list would be allowed and not be returned as potential PII. 

186 :param regex_flags: regex flags to be used for when allow_list_match is "regex" 

187 :param nlp_artifacts: precomputed NlpArtifacts 

188 :return: an array of the found entities in the text 

189 

190 :Example: 

191 

192 ```python 

193 from presidio_analyzer import AnalyzerEngine 

194 

195 # Set up the engine, loads the NLP module (spaCy model by default) 

196 # and other PII recognizers 

197 analyzer = AnalyzerEngine() 

198 

199 # Call analyzer to get results 

200 results = analyzer.analyze(text='My phone number is 212-555-5555', entities=['PHONE_NUMBER'], language='en') 

201 print(results) 

202 ``` 

203 

204 """ # noqa: E501 

205 

206 all_fields = not entities 

207 

208 recognizers = self.registry.get_recognizers( 

209 language=language, 

210 entities=entities, 

211 all_fields=all_fields, 

212 ad_hoc_recognizers=ad_hoc_recognizers, 

213 ) 

214 

215 if all_fields: 

216 # Since all_fields=True, list all entities by iterating 

217 # over all recognizers 

218 entities = self.get_supported_entities(language=language) 

219 

220 # run the nlp pipeline over the given text, store the results in 

221 # a NlpArtifacts instance 

222 if not nlp_artifacts: 

223 nlp_artifacts = self.nlp_engine.process_text(text, language) 

224 

225 if self.log_decision_process: 

226 self.app_tracer.trace( 

227 correlation_id, "nlp artifacts:" + nlp_artifacts.to_json() 

228 ) 

229 

230 results = [] 

231 for recognizer in recognizers: 

232 # Lazy loading of the relevant recognizers 

233 if not recognizer.is_loaded: 

234 recognizer.load() 

235 recognizer.is_loaded = True 

236 

237 # analyze using the current recognizer and append the results 

238 current_results = recognizer.analyze( 

239 text=text, entities=entities, nlp_artifacts=nlp_artifacts 

240 ) 

241 if current_results: 

242 # add recognizer name to recognition metadata inside results 

243 # if not exists 

244 self.__add_recognizer_id_if_not_exists(current_results, recognizer) 

245 results.extend(current_results) 

246 

247 results = self._enhance_using_context( 

248 text, results, nlp_artifacts, recognizers, context 

249 ) 

250 

251 if self.log_decision_process: 

252 self.app_tracer.trace( 

253 correlation_id, 

254 json.dumps([str(result.to_dict()) for result in results]), 

255 ) 

256 

257 # Remove duplicates or low score results 

258 results = EntityRecognizer.remove_duplicates(results) 

259 results = self.__remove_low_scores(results, score_threshold) 

260 

261 if allow_list: 

262 results = self._remove_allow_list( 

263 results, allow_list, text, regex_flags, allow_list_match 

264 ) 

265 

266 if not return_decision_process: 

267 results = self.__remove_decision_process(results) 

268 

269 return results 

270 

271 def _enhance_using_context( 

272 self, 

273 text: str, 

274 raw_results: List[RecognizerResult], 

275 nlp_artifacts: NlpArtifacts, 

276 recognizers: List[EntityRecognizer], 

277 context: Optional[List[str]] = None, 

278 ) -> List[RecognizerResult]: 

279 """ 

280 Enhance confidence score using context words. 

281 

282 :param text: The actual text that was analyzed 

283 :param raw_results: Recognizer results which didn't take 

284 context into consideration 

285 :param nlp_artifacts: The nlp artifacts contains elements 

286 such as lemmatized tokens for better 

287 accuracy of the context enhancement process 

288 :param recognizers: the list of recognizers 

289 :param context: list of context words 

290 """ 

291 results = [] 

292 

293 for recognizer in recognizers: 

294 recognizer_results = [ 

295 r 

296 for r in raw_results 

297 if r.recognition_metadata[RecognizerResult.RECOGNIZER_IDENTIFIER_KEY] 

298 == recognizer.id 

299 ] 

300 other_recognizer_results = [ 

301 r 

302 for r in raw_results 

303 if r.recognition_metadata[RecognizerResult.RECOGNIZER_IDENTIFIER_KEY] 

304 != recognizer.id 

305 ] 

306 

307 # enhance score using context in recognizer level if implemented 

308 recognizer_results = recognizer.enhance_using_context( 

309 text=text, 

310 # each recognizer will get access to all recognizer results 

311 # to allow related entities contex enhancement 

312 raw_recognizer_results=recognizer_results, 

313 other_raw_recognizer_results=other_recognizer_results, 

314 nlp_artifacts=nlp_artifacts, 

315 context=context, 

316 ) 

317 

318 results.extend(recognizer_results) 

319 

320 # Update results in case surrounding words or external context are relevant to 

321 # the context words. 

322 results = self.context_aware_enhancer.enhance_using_context( 

323 text=text, 

324 raw_results=results, 

325 nlp_artifacts=nlp_artifacts, 

326 recognizers=recognizers, 

327 context=context, 

328 ) 

329 

330 return results 

331 

332 def __remove_low_scores( 

333 self, results: List[RecognizerResult], score_threshold: float = None 

334 ) -> List[RecognizerResult]: 

335 """ 

336 Remove results for which the confidence is lower than the threshold. 

337 

338 :param results: List of RecognizerResult 

339 :param score_threshold: float value for minimum possible confidence 

340 :return: List[RecognizerResult] 

341 """ 

342 if score_threshold is None: 

343 score_threshold = self.default_score_threshold 

344 

345 new_results = [result for result in results if result.score >= score_threshold] 

346 return new_results 

347 

348 @staticmethod 

349 def _remove_allow_list( 

350 results: List[RecognizerResult], 

351 allow_list: List[str], 

352 text: str, 

353 regex_flags: Optional[int], 

354 allow_list_match: str, 

355 ) -> List[RecognizerResult]: 

356 """ 

357 Remove results which are part of the allow list. 

358 

359 :param results: List of RecognizerResult 

360 :param allow_list: list of allowed terms 

361 :param text: the text to analyze 

362 :param regex_flags: regex flags to be used for when allow_list_match is "regex" 

363 :param allow_list_match: How the allow_list 

364 should be interpreted; either as "exact" or as "regex" 

365 :return: List[RecognizerResult] 

366 """ 

367 new_results = [] 

368 if allow_list_match == "regex": 

369 pattern = "|".join(allow_list) 

370 re_compiled = re.compile(pattern, flags=regex_flags) 

371 

372 for result in results: 

373 word = text[result.start : result.end] 

374 

375 # if the word is not specified to be allowed, keep in the PII entities 

376 try: 

377 if not re_compiled.search(word, timeout=REGEX_TIMEOUT_SECONDS): 

378 new_results.append(result) 

379 except TimeoutError: 

380 logger.warning( 

381 "Allow list regex timed out after %s seconds" 

382 " (word length: %d), keeping result.", 

383 REGEX_TIMEOUT_SECONDS, 

384 len(word), 

385 exc_info=True, 

386 ) 

387 new_results.append(result) 

388 elif allow_list_match == "exact": 

389 for result in results: 

390 word = text[result.start : result.end] 

391 

392 # if the word is not specified to be allowed, keep in the PII entities 

393 if word not in allow_list: 

394 new_results.append(result) 

395 else: 

396 raise ValueError( 

397 "allow_list_match must either be set to 'exact' or 'regex'." 

398 ) 

399 

400 return new_results 

401 

402 @staticmethod 

403 def __add_recognizer_id_if_not_exists( 

404 results: List[RecognizerResult], recognizer: EntityRecognizer 

405 ) -> None: 

406 """Ensure recognition metadata with recognizer id existence. 

407 

408 Ensure recognizer result list contains recognizer id inside recognition 

409 metadata dictionary, and if not create it. recognizer_id is needed 

410 for context aware enhancement. 

411 

412 :param results: List of RecognizerResult 

413 :param recognizer: Entity recognizer 

414 """ 

415 for result in results: 

416 if not result.recognition_metadata: 

417 result.recognition_metadata = dict() 

418 if ( 

419 RecognizerResult.RECOGNIZER_IDENTIFIER_KEY 

420 not in result.recognition_metadata 

421 ): 

422 result.recognition_metadata[ 

423 RecognizerResult.RECOGNIZER_IDENTIFIER_KEY 

424 ] = recognizer.id 

425 if RecognizerResult.RECOGNIZER_NAME_KEY not in result.recognition_metadata: 

426 result.recognition_metadata[RecognizerResult.RECOGNIZER_NAME_KEY] = ( 

427 recognizer.name 

428 ) 

429 

430 @staticmethod 

431 def __remove_decision_process( 

432 results: List[RecognizerResult], 

433 ) -> List[RecognizerResult]: 

434 """Remove decision process / analysis explanation from response.""" 

435 

436 for result in results: 

437 result.analysis_explanation = None 

438 

439 return results