solidprivacy commited on
Commit
72adc15
·
verified ·
1 Parent(s): fda6e66

Newer version of Presidio Streamlit

Browse files
Files changed (1) hide show
  1. presidio_streamlit.py +179 -242
presidio_streamlit.py CHANGED
@@ -1,4 +1,12 @@
1
- """Streamlit app for Presidio."""
 
 
 
 
 
 
 
 
2
  import logging
3
  import os
4
  import traceback
@@ -19,7 +27,6 @@ from presidio_helpers import (
19
  create_fake_data,
20
  analyzer_engine,
21
  )
22
-
23
  from document_tools import (
24
  uploaded_file_to_text,
25
  build_placeholder_replacements,
@@ -28,8 +35,8 @@ from document_tools import (
28
  docx_from_text,
29
  pdf_from_text,
30
  replacement_report_csv,
 
31
  )
32
-
33
  from replacement_memory import (
34
  load_remembered_replacements,
35
  save_remembered_replacements,
@@ -38,42 +45,61 @@ from replacement_memory import (
38
  )
39
 
40
  try:
41
- from dutch_recognizers import get_dutch_entity_names
42
- except Exception: # keep app usable while the new file is being added
43
- def get_dutch_entity_names():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  return []
45
 
 
 
 
 
46
  st.set_page_config(
47
- page_title="Presidio demo",
48
  layout="wide",
49
  initial_sidebar_state="expanded",
50
- menu_items={
51
- "About": "https://microsoft.github.io/presidio/",
52
- },
53
  )
54
 
55
  dotenv.load_dotenv()
56
  logger = logging.getLogger("presidio-streamlit")
57
-
58
-
59
  allow_other_models = os.getenv("ALLOW_OTHER_MODELS", False)
60
 
61
 
62
  # Sidebar
63
  st.sidebar.header(
64
  """
65
- PII De-Identification with [Microsoft Presidio](https://microsoft.github.io/presidio/)
66
- """
 
 
67
  )
68
 
69
-
70
  model_help_text = """
71
- Select which Named Entity Recognition (NER) model to use for PII detection, in parallel to rule-based recognizers.
72
- Presidio supports multiple NER packages off-the-shelf, such as spaCy, Huggingface, Stanza and Flair,
73
- as well as service such as Azure Text Analytics PII.
74
- """
75
- st_ta_key = st_ta_endpoint = ""
76
 
 
77
  model_list = [
78
  "spaCy/en_core_web_lg",
79
  "flair/ner-english-large",
@@ -85,7 +111,7 @@ model_list = [
85
  ]
86
  if not allow_other_models:
87
  model_list.pop()
88
- # Select model
89
  st_model = st.sidebar.selectbox(
90
  "NER model package",
91
  model_list,
@@ -93,10 +119,7 @@ st_model = st.sidebar.selectbox(
93
  help=model_help_text,
94
  )
95
 
96
- # Extract model package.
97
  st_model_package = st_model.split("/")[0]
98
-
99
- # Remove package prefix (if needed)
100
  st_model = (
101
  st_model
102
  if st_model_package.lower() not in ("spacy", "stanza", "huggingface")
@@ -107,31 +130,31 @@ if st_model == "Other":
107
  st_model_package = st.sidebar.selectbox(
108
  "NER model OSS package", options=["spaCy", "stanza", "Flair", "HuggingFace"]
109
  )
110
- st_model = st.sidebar.text_input(f"NER model name", value="")
111
 
112
  if st_model == "Azure AI Language":
113
  st_ta_key = st.sidebar.text_input(
114
- f"Azure AI Language key", value=os.getenv("TA_KEY", ""), type="password"
115
  )
116
  st_ta_endpoint = st.sidebar.text_input(
117
- f"Azure AI Language endpoint",
118
  value=os.getenv("TA_ENDPOINT", default=""),
119
- help="For more info: https://learn.microsoft.com/en-us/azure/cognitive-services/language-service/personally-identifiable-information/overview", # noqa: E501
120
  )
121
 
122
-
123
- st.sidebar.warning("Note: Models might take some time to download. ")
124
-
125
  analyzer_params = (st_model_package, st_model, st_ta_key, st_ta_endpoint)
126
- logger.debug(f"analyzer_params: {analyzer_params}")
127
 
128
  st_recognition_profile = st.sidebar.selectbox(
129
  "Recognition profile",
130
- ["Dutch / EU", "General / International"],
131
  index=0,
132
  help=(
133
- "Dutch / EU enables Dutch pattern recognizers such as BSN, postcode, "
134
- "KvK, BTW/VAT, Dutch IBAN, Dutch phone numbers and Dutch license plates."
 
 
135
  ),
136
  )
137
 
@@ -140,47 +163,44 @@ st_operator = st.sidebar.selectbox(
140
  ["redact", "replace", "synthesize", "highlight", "mask", "hash", "encrypt"],
141
  index=1,
142
  help="""
143
- Select which manipulation to the text is requested after PII has been identified.\n
144
- - Redact: Completely remove the PII text\n
145
- - Replace: Replace the PII text with a constant, e.g. <PERSON>\n
146
- - Synthesize: Replace with fake values (requires an OpenAI key)\n
147
- - Highlight: Shows the original text with PII highlighted in colors\n
148
- - Mask: Replaces a requested number of characters with an asterisk (or other mask character)\n
149
- - Hash: Replaces with the hash of the PII string\n
150
- - Encrypt: Replaces with an AES encryption of the PII string, allowing the process to be reversed
151
- """,
152
  )
 
153
  st_mask_char = "*"
154
  st_number_of_chars = 15
155
  st_encrypt_key = "WmZq4t7w!z%C&F)J"
156
-
157
  open_ai_params = None
158
-
159
- logger.debug(f"st_operator: {st_operator}")
160
 
161
 
162
  def set_up_openai_synthesis():
163
  """Set up the OpenAI API key and model for text synthesis."""
164
-
165
  if os.getenv("OPENAI_TYPE", default="openai") == "Azure":
166
  openai_api_type = "azure"
167
  st_openai_api_base = st.sidebar.text_input(
168
- "Azure OpenAI base URL",
169
- value=os.getenv("AZURE_OPENAI_ENDPOINT", default=""),
170
  )
171
  openai_key = os.getenv("AZURE_OPENAI_KEY", default="")
172
  st_deployment_id = st.sidebar.text_input(
173
  "Deployment name", value=os.getenv("AZURE_OPENAI_DEPLOYMENT", default="")
174
  )
175
  st_openai_version = st.sidebar.text_input(
176
- "OpenAI version",
177
- value=os.getenv("OPENAI_API_VERSION", default="2023-05-15"),
178
  )
179
  else:
180
  openai_api_type = "openai"
181
  st_openai_version = st_openai_api_base = None
182
  st_deployment_id = ""
183
  openai_key = os.getenv("OPENAI_KEY", default="")
 
184
  st_openai_key = st.sidebar.text_input(
185
  "OPENAI_KEY",
186
  value=openai_key,
@@ -206,9 +226,7 @@ if st_operator == "mask":
206
  st_number_of_chars = st.sidebar.number_input(
207
  "number of chars", value=st_number_of_chars, min_value=0, max_value=100
208
  )
209
- st_mask_char = st.sidebar.text_input(
210
- "Mask character", value=st_mask_char, max_chars=1
211
- )
212
  elif st_operator == "encrypt":
213
  st_encrypt_key = st.sidebar.text_input("AES key", value=st_encrypt_key)
214
  elif st_operator == "synthesize":
@@ -220,7 +238,6 @@ elif st_operator == "synthesize":
220
  st_openai_key,
221
  st_openai_model,
222
  ) = set_up_openai_synthesis()
223
-
224
  open_ai_params = OpenAIParams(
225
  openai_key=st_openai_key,
226
  model=st_openai_model,
@@ -230,91 +247,73 @@ elif st_operator == "synthesize":
230
  api_type=openai_api_type,
231
  )
232
 
 
233
  st_threshold = st.sidebar.slider(
234
  label="Acceptance threshold",
235
  min_value=0.0,
236
  max_value=1.0,
237
- value=0.35,
238
- help="Define the threshold for accepting a detection as PII. See more here: ",
239
  )
240
 
241
  st_return_decision_process = st.sidebar.checkbox(
242
  "Add analysis explanations to findings",
243
  value=False,
244
- help="Add the decision process to the output table. "
245
- "More information can be found here: https://microsoft.github.io/presidio/analyzer/decision_process/",
246
- )
247
-
248
- # Allow and deny lists
249
- st_deny_allow_expander = st.sidebar.expander(
250
- "Allowlists and denylists",
251
- expanded=False,
252
  )
253
 
 
254
  with st_deny_allow_expander:
255
- st_allow_list = st_tags(
256
- label="Add words to the allowlist", text="Enter word and press enter."
257
- )
258
- st.caption(
259
- "Allowlists contain words that are not considered PII, but are detected as such."
260
- )
261
 
262
- st_deny_list = st_tags(
263
- label="Add words to the denylist", text="Enter word and press enter."
264
- )
265
- st.caption(
266
- "Denylists contain words that are considered PII, but are not detected as such."
267
- )
268
- # Main panel
269
 
 
270
  with st.expander("About this demo", expanded=False):
271
  st.info(
272
- """Presidio is an open source customizable framework for PII detection and de-identification.
273
- \n\n[Code](https://aka.ms/presidio) |
274
- [Tutorial](https://microsoft.github.io/presidio/tutorial/) |
275
- [Installation](https://microsoft.github.io/presidio/installation/) |
 
276
  [FAQ](https://microsoft.github.io/presidio/faq/) |
277
- [Feedback](https://forms.office.com/r/9ufyYjfDaY) |"""
 
278
  )
279
-
280
  st.info(
281
  """
282
- Use this demo to:
283
- - Experiment with different off-the-shelf models and NLP packages.
284
- - Explore the different de-identification options, including redaction, masking, encryption and more.
285
- - Generate synthetic text with Microsoft Presidio and OpenAI.
286
- - Configure allow and deny lists.
287
-
288
- This demo website shows some of Presidio's capabilities.
289
- [Visit our website](https://microsoft.github.io/presidio) for more info,
290
- samples and deployment options.
291
- """
292
- )
293
-
294
- st.markdown(
295
- "[![Pypi Downloads](https://img.shields.io/pypi/dm/presidio-analyzer.svg)](https://img.shields.io/pypi/dm/presidio-analyzer.svg)" # noqa
296
- "[![MIT license](https://img.shields.io/badge/license-MIT-brightgreen.svg)](https://opensource.org/licenses/MIT)"
297
- "![GitHub Repo stars](https://img.shields.io/github/stars/microsoft/presidio?style=social)"
298
  )
299
 
300
  analyzer_load_state = st.info("Starting Presidio analyzer...")
301
-
302
  analyzer_load_state.empty()
303
 
304
- if st_recognition_profile == "Dutch / EU":
 
 
 
 
 
 
305
  st.info(
306
- "Dutch / EU mode is active. The app adds Dutch pattern recognizers "
307
- "for BSN, postcode, KvK, BTW/VAT, Dutch IBAN, Dutch phone numbers, "
308
- "license plates, rijbewijs-style numbers and BIG numbers. Always review "
309
- "the editable replacement table before exporting."
310
  )
311
 
312
  # Read default text
313
- with open("demo_text.txt") as f:
314
  demo_text = f.readlines()
315
 
316
  st.subheader("Document input")
317
-
318
  uploaded_file = st.file_uploader(
319
  "Upload a .txt, .docx, or text-based .pdf file",
320
  type=["txt", "docx", "pdf"],
@@ -324,6 +323,17 @@ uploaded_file = st.file_uploader(
324
  uploaded_file_type = None
325
  input_text = "".join(demo_text)
326
 
 
 
 
 
 
 
 
 
 
 
 
327
  if uploaded_file is not None:
328
  try:
329
  input_text, uploaded_file_type = uploaded_file_to_text(uploaded_file)
@@ -331,12 +341,8 @@ if uploaded_file is not None:
331
  except Exception as upload_error:
332
  st.error(f"Could not read uploaded file: {upload_error}")
333
 
334
- # Create two columns for before and after
335
  col1, col2 = st.columns(2)
336
-
337
- # Before:
338
  col1.subheader("Input")
339
-
340
  st_text = col1.text_area(
341
  label="Enter text or review extracted document text",
342
  value=input_text,
@@ -345,46 +351,51 @@ st_text = col1.text_area(
345
  )
346
 
347
  try:
348
- # Choose entities
349
  all_supported_entities = list(get_supported_entities(*analyzer_params))
350
- dutch_entities = set(get_dutch_entity_names())
351
-
352
- if st_recognition_profile == "Dutch / EU":
353
- preferred_entities = {
354
- "PERSON",
355
- "LOCATION",
356
- "ORGANIZATION",
357
- "EMAIL_ADDRESS",
358
- "PHONE_NUMBER",
359
- "IBAN_CODE",
360
- "URL",
361
- "IP_ADDRESS",
362
- "GENERIC_PII",
363
- } | dutch_entities
364
- default_entities = [
365
- entity for entity in all_supported_entities if entity in preferred_entities
366
- ]
 
 
 
 
367
  else:
368
- default_entities = list(all_supported_entities)
 
 
369
 
370
  st_entities_expander = st.sidebar.expander("Choose entities to look for")
371
  st_entities = st_entities_expander.multiselect(
372
  label="Which entities to look for?",
373
  options=all_supported_entities,
374
  default=default_entities,
375
- help="Limit the list of PII entities detected. "
376
- "Dutch / EU mode adds recognizers such as NL_BSN, NL_POSTCODE, "
377
- "NL_KVK_NUMBER, NL_VAT_NUMBER, NL_IBAN and NL_PHONE_NUMBER.",
 
 
378
  )
379
 
380
- # Before
381
  analyzer_load_state = st.info("Starting Presidio analyzer...")
382
  analyzer = analyzer_engine(*analyzer_params)
383
  analyzer_load_state.empty()
384
 
385
- # The current demo uses English NER models. Dutch/EU pattern recognizers
386
- # are registered under language="en" so they can run without requiring a
387
- # separate Dutch NLP model.
388
  st_analyze_results = analyze(
389
  *analyzer_params,
390
  text=st_text,
@@ -396,10 +407,9 @@ try:
396
  deny_list=st_deny_list,
397
  )
398
 
399
- # After
400
  if st_operator not in ("highlight", "synthesize"):
401
  with col2:
402
- st.subheader(f"Output")
403
  st_anonymize_results = anonymize(
404
  text=st_text,
405
  operator=st_operator,
@@ -408,37 +418,26 @@ try:
408
  encrypt_key=st_encrypt_key,
409
  analyze_results=st_analyze_results,
410
  )
411
- st.text_area(
412
- label="De-identified", value=st_anonymize_results.text, height=400
413
- )
414
- # Build stable placeholder suggestions from Presidio
415
- replacements, report_rows = build_placeholder_replacements(
416
- st_text,
417
- st_analyze_results,
418
- )
419
 
420
  st.divider()
421
  st.subheader("Review replacement table before export")
422
-
423
  st.caption(
424
  "Untick false positives, change placeholders, add your own word pairs, "
425
  "and tick Remember for pairs you want to reuse in future documents."
426
  )
427
 
428
- # Build editable table rows from remembered replacements + Presidio suggestions
429
  remembered_rows = load_remembered_replacements()
430
-
431
  default_editor_rows = []
432
  seen_find_values = set()
433
 
434
- # First load remembered pairs
435
  for row in remembered_rows:
436
  find_text = str(row.get("find", "")).strip()
437
  replace_with = str(row.get("replace_with", "")).strip()
438
-
439
  if not find_text or not replace_with:
440
  continue
441
-
442
  default_editor_rows.append(
443
  {
444
  "include": row.get("include", True),
@@ -451,16 +450,10 @@ try:
451
  )
452
  seen_find_values.add(find_text)
453
 
454
- # Then add Presidio suggestions, unless already covered by a remembered pair
455
  for row in report_rows:
456
  find_text = str(row.get("detected_text", "")).strip()
457
-
458
- if not find_text:
459
- continue
460
-
461
- if find_text in seen_find_values:
462
  continue
463
-
464
  default_editor_rows.append(
465
  {
466
  "include": True,
@@ -472,7 +465,6 @@ try:
472
  }
473
  )
474
 
475
- # If nothing was detected and nothing was remembered, still show an empty editable row
476
  if not default_editor_rows:
477
  default_editor_rows = [
478
  {
@@ -486,7 +478,6 @@ try:
486
  ]
487
 
488
  replacement_editor_df = pd.DataFrame(default_editor_rows)
489
-
490
  edited_replacements_df = st.data_editor(
491
  replacement_editor_df,
492
  hide_index=True,
@@ -495,31 +486,22 @@ try:
495
  column_order=["include", "remember", "find", "replace_with", "entity_type", "score"],
496
  column_config={
497
  "include": st.column_config.CheckboxColumn(
498
- "Use",
499
- help="Untick to exclude this replacement from the export.",
500
- default=True,
501
  ),
502
  "remember": st.column_config.CheckboxColumn(
503
- "Remember",
504
- help="Save this replacement pair for future documents/sessions.",
505
- default=False,
506
  ),
507
  "find": st.column_config.TextColumn(
508
- "Find text",
509
- help="The exact text that should be replaced.",
510
  ),
511
  "replace_with": st.column_config.TextColumn(
512
- "Replace with",
513
- help="The placeholder to insert.",
514
  ),
515
  "entity_type": st.column_config.TextColumn(
516
- "Entity type",
517
- help="Presidio entity type or MANUAL.",
518
  ),
519
  "score": st.column_config.NumberColumn(
520
- "Score",
521
- help="Presidio confidence score, if available.",
522
- format="%.3f",
523
  ),
524
  },
525
  key="replacement_editor",
@@ -549,25 +531,17 @@ try:
549
  return bool(value)
550
  return str(value).strip().lower() in ("true", "1", "yes", "y", "checked")
551
 
552
- # Build final replacements from edited table
553
  edited_replacements = {}
554
  edited_report_rows = []
555
-
556
  for _, row in edited_replacements_df.iterrows():
557
  include = safe_bool(row.get("include", False))
558
  find_text = safe_cell(row.get("find", ""))
559
  replace_text = safe_cell(row.get("replace_with", ""))
560
  entity_type = safe_cell(row.get("entity_type", "MANUAL")) or "MANUAL"
561
  score = row.get("score", None)
562
-
563
- if not include:
564
- continue
565
-
566
- if not find_text or not replace_text:
567
  continue
568
-
569
  edited_replacements[find_text] = replace_text
570
-
571
  edited_report_rows.append(
572
  {
573
  "entity_type": entity_type,
@@ -579,58 +553,40 @@ try:
579
 
580
  st.info(f"{len(edited_replacements)} replacement pair(s) will be applied to the exports.")
581
 
582
- # Apply edited replacements
583
  export_text = apply_replacements_to_text(st_text, edited_replacements)
584
-
585
  with st.expander("Preview anonymized text generated from edited table", expanded=False):
586
- st.text_area(
587
- label="Preview",
588
- value=export_text,
589
- height=300,
590
- key="edited_export_preview",
591
- )
592
 
593
  st.subheader("Remember reusable replacements")
594
-
595
  remember_rows_to_save = []
596
-
597
  for _, row in edited_replacements_df.iterrows():
598
  include = safe_bool(row.get("include", False))
599
  remember = safe_bool(row.get("remember", False))
600
  find_text = safe_cell(row.get("find", ""))
601
  replace_text = safe_cell(row.get("replace_with", ""))
602
  entity_type = safe_cell(row.get("entity_type", "REMEMBERED")) or "REMEMBERED"
603
-
604
  if include and remember and find_text and replace_text:
605
  remember_rows_to_save.append(
606
- {
607
- "find": find_text,
608
- "replace_with": replace_text,
609
- "entity_type": entity_type,
610
- }
611
  )
612
 
613
  memory_col1, memory_col2 = st.columns(2)
614
-
615
  with memory_col1:
616
  if st.button("Save remembered replacements"):
617
  saved_count = save_remembered_replacements(remember_rows_to_save)
618
  st.success(f"Saved {saved_count} remembered replacement pair(s).")
619
  st.info(f"Memory file: {get_memory_file_path()}")
620
-
621
  with memory_col2:
622
  if st.button("Clear remembered replacements"):
623
  clear_remembered_replacements()
624
  st.warning("Remembered replacements cleared.")
625
 
626
  st.subheader("Export anonymized files")
627
-
628
  if uploaded_file is not None:
629
  st.info(f"Uploaded file detected for export: {uploaded_file.name}")
630
  else:
631
  st.info("No uploaded file detected for export. Exporting from text area only.")
632
 
633
- # TXT export
634
  st.download_button(
635
  label="Download anonymized text (.txt)",
636
  data=export_text.encode("utf-8"),
@@ -638,8 +594,6 @@ try:
638
  mime="text/plain",
639
  key="download_txt",
640
  )
641
-
642
- # CSV replacement report / reusable mapping
643
  st.download_button(
644
  label="Download replacement table (.csv)",
645
  data=replacement_report_csv(edited_report_rows),
@@ -647,8 +601,18 @@ try:
647
  mime="text/csv",
648
  key="download_csv",
649
  )
 
 
 
 
 
 
 
 
 
 
 
650
 
651
- # DOCX export
652
  try:
653
  if uploaded_file is not None and uploaded_file.name.lower().endswith(".docx"):
654
  docx_bytes = anonymized_docx_from_original(uploaded_file, edited_replacements)
@@ -656,7 +620,6 @@ try:
656
  else:
657
  docx_bytes = docx_from_text(export_text)
658
  docx_filename = "anonymized_text.docx"
659
-
660
  st.download_button(
661
  label="Download anonymized Word file (.docx)",
662
  data=docx_bytes,
@@ -664,11 +627,9 @@ try:
664
  mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
665
  key="download_docx",
666
  )
667
-
668
  except Exception as docx_error:
669
  st.error(f"Could not create DOCX export: {docx_error}")
670
 
671
- # PDF export
672
  try:
673
  st.download_button(
674
  label="Download anonymized PDF (.pdf)",
@@ -677,35 +638,23 @@ try:
677
  mime="application/pdf",
678
  key="download_pdf",
679
  )
680
-
681
  except Exception as pdf_error:
682
  st.error(f"Could not create PDF export: {pdf_error}")
683
 
684
  elif st_operator == "synthesize":
685
  with col2:
686
- st.subheader(f"OpenAI Generated output")
687
- fake_data = create_fake_data(
688
- st_text,
689
- st_analyze_results,
690
- open_ai_params,
691
- )
692
  st.text_area(label="Synthetic data", value=fake_data, height=400)
693
  else:
694
  st.subheader("Highlighted")
695
  annotated_tokens = annotate(text=st_text, analyze_results=st_analyze_results)
696
- # annotated_tokens
697
  annotated_text(*annotated_tokens)
698
 
699
- # table result
700
- st.subheader(
701
- "Findings"
702
- if not st_return_decision_process
703
- else "Findings with decision factors"
704
- )
705
  if st_analyze_results:
706
  df = pd.DataFrame.from_records([r.to_dict() for r in st_analyze_results])
707
  df["text"] = [st_text[res.start : res.end] for res in st_analyze_results]
708
-
709
  df_subset = df[["entity_type", "text", "start", "end", "score"]].rename(
710
  {
711
  "entity_type": "Entity type",
@@ -716,7 +665,6 @@ try:
716
  },
717
  axis=1,
718
  )
719
- df_subset["Text"] = [st_text[res.start : res.end] for res in st_analyze_results]
720
  if st_return_decision_process:
721
  analysis_explanation_df = pd.DataFrame.from_records(
722
  [r.analysis_explanation.to_dict() for r in st_analyze_results]
@@ -731,15 +679,4 @@ except Exception as e:
731
  traceback.print_exc()
732
  st.error(e)
733
 
734
- components.html(
735
- """
736
- <script type="text/javascript">
737
- (function(c,l,a,r,i,t,y){
738
- c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
739
- t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
740
- y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
741
- })(window, document, "clarity", "script", "h7f8bp42n8");
742
- </script>
743
- """
744
- )
745
-
 
1
+ """Streamlit app for SolidPrivacy Scrub / Microsoft Presidio.
2
+
3
+ Phase 1-3 update:
4
+ - Dutch Legal Strict recognition profile;
5
+ - Dutch legal test examples;
6
+ - legal-aware replacement labels and scrub report download;
7
+ - keeps current workflow: upload -> detect -> editable replacement table -> export.
8
+ """
9
+
10
  import logging
11
  import os
12
  import traceback
 
27
  create_fake_data,
28
  analyzer_engine,
29
  )
 
30
  from document_tools import (
31
  uploaded_file_to_text,
32
  build_placeholder_replacements,
 
35
  docx_from_text,
36
  pdf_from_text,
37
  replacement_report_csv,
38
+ scrub_report_txt,
39
  )
 
40
  from replacement_memory import (
41
  load_remembered_replacements,
42
  save_remembered_replacements,
 
45
  )
46
 
47
  try:
48
+ from dutch_recognizers import (
49
+ get_dutch_entity_names,
50
+ get_dutch_general_entity_names,
51
+ get_dutch_legal_entity_names,
52
+ )
53
+ except Exception: # keep app usable while new file is being added
54
+ def get_dutch_entity_names(include_legal=True):
55
+ return []
56
+
57
+ def get_dutch_general_entity_names():
58
+ return []
59
+
60
+ def get_dutch_legal_entity_names():
61
+ return []
62
+
63
+ try:
64
+ from legal_test_examples import TEST_CASES, get_example_names, get_example_text
65
+ except Exception:
66
+ TEST_CASES = []
67
+
68
+ def get_example_names():
69
  return []
70
 
71
+ def get_example_text(name: str):
72
+ return ""
73
+
74
+
75
  st.set_page_config(
76
+ page_title="SolidPrivacy Scrub",
77
  layout="wide",
78
  initial_sidebar_state="expanded",
79
+ menu_items={"About": "https://microsoft.github.io/presidio/"},
 
 
80
  )
81
 
82
  dotenv.load_dotenv()
83
  logger = logging.getLogger("presidio-streamlit")
 
 
84
  allow_other_models = os.getenv("ALLOW_OTHER_MODELS", False)
85
 
86
 
87
  # Sidebar
88
  st.sidebar.header(
89
  """
90
+ SolidPrivacy Scrub
91
+
92
+ PII De-Identification with [Microsoft Presidio](https://microsoft.github.io/presidio/)
93
+ """
94
  )
95
 
 
96
  model_help_text = """
97
+ Select which Named Entity Recognition (NER) model to use for PII detection,
98
+ in parallel to rule-based recognizers. The Dutch Legal Strict layer is rule-based
99
+ and does not require a cloud model.
100
+ """
 
101
 
102
+ st_ta_key = st_ta_endpoint = ""
103
  model_list = [
104
  "spaCy/en_core_web_lg",
105
  "flair/ner-english-large",
 
111
  ]
112
  if not allow_other_models:
113
  model_list.pop()
114
+
115
  st_model = st.sidebar.selectbox(
116
  "NER model package",
117
  model_list,
 
119
  help=model_help_text,
120
  )
121
 
 
122
  st_model_package = st_model.split("/")[0]
 
 
123
  st_model = (
124
  st_model
125
  if st_model_package.lower() not in ("spacy", "stanza", "huggingface")
 
130
  st_model_package = st.sidebar.selectbox(
131
  "NER model OSS package", options=["spaCy", "stanza", "Flair", "HuggingFace"]
132
  )
133
+ st_model = st.sidebar.text_input("NER model name", value="")
134
 
135
  if st_model == "Azure AI Language":
136
  st_ta_key = st.sidebar.text_input(
137
+ "Azure AI Language key", value=os.getenv("TA_KEY", ""), type="password"
138
  )
139
  st_ta_endpoint = st.sidebar.text_input(
140
+ "Azure AI Language endpoint",
141
  value=os.getenv("TA_ENDPOINT", default=""),
142
+ help="For more info: https://learn.microsoft.com/en-us/azure/cognitive-services/language-service/personally-identifiable-information/overview",
143
  )
144
 
145
+ st.sidebar.warning("Note: some NER models might take time to download/load.")
 
 
146
  analyzer_params = (st_model_package, st_model, st_ta_key, st_ta_endpoint)
147
+ logger.debug("analyzer_params: %s", analyzer_params)
148
 
149
  st_recognition_profile = st.sidebar.selectbox(
150
  "Recognition profile",
151
+ ["Dutch Legal Strict", "Dutch / EU", "General / International"],
152
  index=0,
153
  help=(
154
+ "Dutch Legal Strict adds Dutch legal/matter identifiers such as zaaknummer, "
155
+ "rolnummer, parketnummer, dossiernummer, cliëntnummer, CJIB and ECLI. "
156
+ "Dutch / EU enables general Dutch identifiers such as BSN, postcode, KvK, BTW/VAT, "
157
+ "Dutch IBAN, Dutch phone numbers and Dutch address patterns."
158
  ),
159
  )
160
 
 
163
  ["redact", "replace", "synthesize", "highlight", "mask", "hash", "encrypt"],
164
  index=1,
165
  help="""
166
+ Select which manipulation is requested after PII has been identified.
167
+ - Redact: completely remove the PII text
168
+ - Replace: replace PII with a placeholder
169
+ - Synthesize: replace with fake values; requires an OpenAI key
170
+ - Highlight: show original text with PII highlighted
171
+ - Mask: replace characters with a mask character
172
+ - Hash: replace with a hash
173
+ - Encrypt: replace with AES encryption, reversible with the key
174
+ """,
175
  )
176
+
177
  st_mask_char = "*"
178
  st_number_of_chars = 15
179
  st_encrypt_key = "WmZq4t7w!z%C&F)J"
 
180
  open_ai_params = None
181
+ logger.debug("st_operator: %s", st_operator)
 
182
 
183
 
184
  def set_up_openai_synthesis():
185
  """Set up the OpenAI API key and model for text synthesis."""
 
186
  if os.getenv("OPENAI_TYPE", default="openai") == "Azure":
187
  openai_api_type = "azure"
188
  st_openai_api_base = st.sidebar.text_input(
189
+ "Azure OpenAI base URL", value=os.getenv("AZURE_OPENAI_ENDPOINT", default="")
 
190
  )
191
  openai_key = os.getenv("AZURE_OPENAI_KEY", default="")
192
  st_deployment_id = st.sidebar.text_input(
193
  "Deployment name", value=os.getenv("AZURE_OPENAI_DEPLOYMENT", default="")
194
  )
195
  st_openai_version = st.sidebar.text_input(
196
+ "OpenAI version", value=os.getenv("OPENAI_API_VERSION", default="2023-05-15")
 
197
  )
198
  else:
199
  openai_api_type = "openai"
200
  st_openai_version = st_openai_api_base = None
201
  st_deployment_id = ""
202
  openai_key = os.getenv("OPENAI_KEY", default="")
203
+
204
  st_openai_key = st.sidebar.text_input(
205
  "OPENAI_KEY",
206
  value=openai_key,
 
226
  st_number_of_chars = st.sidebar.number_input(
227
  "number of chars", value=st_number_of_chars, min_value=0, max_value=100
228
  )
229
+ st_mask_char = st.sidebar.text_input("Mask character", value=st_mask_char, max_chars=1)
 
 
230
  elif st_operator == "encrypt":
231
  st_encrypt_key = st.sidebar.text_input("AES key", value=st_encrypt_key)
232
  elif st_operator == "synthesize":
 
238
  st_openai_key,
239
  st_openai_model,
240
  ) = set_up_openai_synthesis()
 
241
  open_ai_params = OpenAIParams(
242
  openai_key=st_openai_key,
243
  model=st_openai_model,
 
247
  api_type=openai_api_type,
248
  )
249
 
250
+ st_threshold_default = 0.30 if st_recognition_profile == "Dutch Legal Strict" else 0.35
251
  st_threshold = st.sidebar.slider(
252
  label="Acceptance threshold",
253
  min_value=0.0,
254
  max_value=1.0,
255
+ value=st_threshold_default,
256
+ help="Define the threshold for accepting a detection as PII.",
257
  )
258
 
259
  st_return_decision_process = st.sidebar.checkbox(
260
  "Add analysis explanations to findings",
261
  value=False,
262
+ help=(
263
+ "Add the decision process to the output table. More information: "
264
+ "https://microsoft.github.io/presidio/analyzer/decision_process/"
265
+ ),
 
 
 
 
266
  )
267
 
268
+ st_deny_allow_expander = st.sidebar.expander("Allowlists and denylists", expanded=False)
269
  with st_deny_allow_expander:
270
+ st_allow_list = st_tags(label="Add words to the allowlist", text="Enter word and press enter.")
271
+ st.caption("Allowlists contain words that are not considered PII, but are detected as such.")
272
+ st_deny_list = st_tags(label="Add words to the denylist", text="Enter word and press enter.")
273
+ st.caption("Denylists contain words that are considered PII, but are not detected as such.")
 
 
274
 
 
 
 
 
 
 
 
275
 
276
+ # Main panel
277
  with st.expander("About this demo", expanded=False):
278
  st.info(
279
+ """
280
+ Presidio is an open source customizable framework for PII detection and de-identification.
281
+
282
+ [Code](https://aka.ms/presidio) | [Tutorial](https://microsoft.github.io/presidio/tutorial/) |
283
+ [Installation](https://microsoft.github.io/presidio/installation/) |
284
  [FAQ](https://microsoft.github.io/presidio/faq/) |
285
+ [Feedback](https://forms.office.com/r/9ufyYjfDaY)
286
+ """
287
  )
 
288
  st.info(
289
  """
290
+ SolidPrivacy Scrub extends the demo with Dutch/EU and Dutch legal recognizers.
291
+ For legal/confidential material, use fake documents in this public Space.
292
+ The recognizer pack is designed to be local/offline compatible for a future desktop/MSI version.
293
+ """
 
 
 
 
 
 
 
 
 
 
 
 
294
  )
295
 
296
  analyzer_load_state = st.info("Starting Presidio analyzer...")
 
297
  analyzer_load_state.empty()
298
 
299
+ if st_recognition_profile == "Dutch Legal Strict":
300
+ st.info(
301
+ "Dutch Legal Strict mode is active. The app adds Dutch/EU recognizers plus legal/matter identifiers: "
302
+ "zaaknummer, rolnummer, rekestnummer, parketnummer, dossiernummer, cliëntnummer, CJIB, ECLI, "
303
+ "legal party references and court/authority references. Always review the editable replacement table."
304
+ )
305
+ elif st_recognition_profile == "Dutch / EU":
306
  st.info(
307
+ "Dutch / EU mode is active. The app adds Dutch pattern recognizers for BSN, postcode, KvK, BTW/VAT, "
308
+ "Dutch IBAN, Dutch phone numbers, addresses, license plates, rijbewijs-style numbers and BIG numbers. "
309
+ "Always review the editable replacement table before exporting."
 
310
  )
311
 
312
  # Read default text
313
+ with open("demo_text.txt", encoding="utf-8") as f:
314
  demo_text = f.readlines()
315
 
316
  st.subheader("Document input")
 
317
  uploaded_file = st.file_uploader(
318
  "Upload a .txt, .docx, or text-based .pdf file",
319
  type=["txt", "docx", "pdf"],
 
323
  uploaded_file_type = None
324
  input_text = "".join(demo_text)
325
 
326
+ if st_recognition_profile == "Dutch Legal Strict" and TEST_CASES:
327
+ with st.expander("Use a fake Dutch legal test example", expanded=False):
328
+ sample_name = st.selectbox(
329
+ "Load synthetic legal example",
330
+ ["Do not load a test example"] + get_example_names(),
331
+ index=0,
332
+ )
333
+ if sample_name != "Do not load a test example" and uploaded_file is None:
334
+ input_text = get_example_text(sample_name)
335
+ st.caption("Loaded synthetic example text. No real personal data is included.")
336
+
337
  if uploaded_file is not None:
338
  try:
339
  input_text, uploaded_file_type = uploaded_file_to_text(uploaded_file)
 
341
  except Exception as upload_error:
342
  st.error(f"Could not read uploaded file: {upload_error}")
343
 
 
344
  col1, col2 = st.columns(2)
 
 
345
  col1.subheader("Input")
 
346
  st_text = col1.text_area(
347
  label="Enter text or review extracted document text",
348
  value=input_text,
 
351
  )
352
 
353
  try:
 
354
  all_supported_entities = list(get_supported_entities(*analyzer_params))
355
+ general_dutch_entities = set(get_dutch_general_entity_names())
356
+ legal_dutch_entities = set(get_dutch_legal_entity_names())
357
+ all_dutch_entities = set(get_dutch_entity_names(include_legal=True))
358
+
359
+ base_preferred_entities = {
360
+ "PERSON",
361
+ "LOCATION",
362
+ "ORGANIZATION",
363
+ "EMAIL_ADDRESS",
364
+ "PHONE_NUMBER",
365
+ "IBAN_CODE",
366
+ "URL",
367
+ "IP_ADDRESS",
368
+ "GENERIC_PII",
369
+ "DATE_TIME",
370
+ }
371
+
372
+ if st_recognition_profile == "Dutch Legal Strict":
373
+ preferred_entities = base_preferred_entities | all_dutch_entities
374
+ elif st_recognition_profile == "Dutch / EU":
375
+ preferred_entities = base_preferred_entities | general_dutch_entities
376
  else:
377
+ preferred_entities = set(all_supported_entities)
378
+
379
+ default_entities = [entity for entity in all_supported_entities if entity in preferred_entities]
380
 
381
  st_entities_expander = st.sidebar.expander("Choose entities to look for")
382
  st_entities = st_entities_expander.multiselect(
383
  label="Which entities to look for?",
384
  options=all_supported_entities,
385
  default=default_entities,
386
+ help=(
387
+ "Dutch / EU mode adds recognizers such as NL_BSN, NL_POSTCODE, NL_KVK_NUMBER, "
388
+ "NL_VAT_NUMBER, NL_IBAN and NL_PHONE_NUMBER. Dutch Legal Strict additionally adds "
389
+ "NL_ECLI, NL_LEGAL_CASE_NUMBER, NL_PARKETNUMMER, NL_DOSSIER_NUMBER, NL_CLIENT_NUMBER and related legal IDs."
390
+ ),
391
  )
392
 
 
393
  analyzer_load_state = st.info("Starting Presidio analyzer...")
394
  analyzer = analyzer_engine(*analyzer_params)
395
  analyzer_load_state.empty()
396
 
397
+ # The current demo uses English NER models. Dutch/EU pattern recognizers are
398
+ # registered under language="en" so they can run without a separate Dutch NLP model.
 
399
  st_analyze_results = analyze(
400
  *analyzer_params,
401
  text=st_text,
 
407
  deny_list=st_deny_list,
408
  )
409
 
 
410
  if st_operator not in ("highlight", "synthesize"):
411
  with col2:
412
+ st.subheader("Output")
413
  st_anonymize_results = anonymize(
414
  text=st_text,
415
  operator=st_operator,
 
418
  encrypt_key=st_encrypt_key,
419
  analyze_results=st_analyze_results,
420
  )
421
+ st.text_area(label="De-identified", value=st_anonymize_results.text, height=400)
422
+
423
+ _, report_rows = build_placeholder_replacements(st_text, st_analyze_results)
 
 
 
 
 
424
 
425
  st.divider()
426
  st.subheader("Review replacement table before export")
 
427
  st.caption(
428
  "Untick false positives, change placeholders, add your own word pairs, "
429
  "and tick Remember for pairs you want to reuse in future documents."
430
  )
431
 
 
432
  remembered_rows = load_remembered_replacements()
 
433
  default_editor_rows = []
434
  seen_find_values = set()
435
 
 
436
  for row in remembered_rows:
437
  find_text = str(row.get("find", "")).strip()
438
  replace_with = str(row.get("replace_with", "")).strip()
 
439
  if not find_text or not replace_with:
440
  continue
 
441
  default_editor_rows.append(
442
  {
443
  "include": row.get("include", True),
 
450
  )
451
  seen_find_values.add(find_text)
452
 
 
453
  for row in report_rows:
454
  find_text = str(row.get("detected_text", "")).strip()
455
+ if not find_text or find_text in seen_find_values:
 
 
 
 
456
  continue
 
457
  default_editor_rows.append(
458
  {
459
  "include": True,
 
465
  }
466
  )
467
 
 
468
  if not default_editor_rows:
469
  default_editor_rows = [
470
  {
 
478
  ]
479
 
480
  replacement_editor_df = pd.DataFrame(default_editor_rows)
 
481
  edited_replacements_df = st.data_editor(
482
  replacement_editor_df,
483
  hide_index=True,
 
486
  column_order=["include", "remember", "find", "replace_with", "entity_type", "score"],
487
  column_config={
488
  "include": st.column_config.CheckboxColumn(
489
+ "Use", help="Untick to exclude this replacement from the export.", default=True
 
 
490
  ),
491
  "remember": st.column_config.CheckboxColumn(
492
+ "Remember", help="Save this replacement pair for future documents/sessions.", default=False
 
 
493
  ),
494
  "find": st.column_config.TextColumn(
495
+ "Find text", help="The exact text that should be replaced."
 
496
  ),
497
  "replace_with": st.column_config.TextColumn(
498
+ "Replace with", help="The placeholder to insert."
 
499
  ),
500
  "entity_type": st.column_config.TextColumn(
501
+ "Entity type", help="Presidio entity type or MANUAL."
 
502
  ),
503
  "score": st.column_config.NumberColumn(
504
+ "Score", help="Presidio confidence score, if available.", format="%.3f"
 
 
505
  ),
506
  },
507
  key="replacement_editor",
 
531
  return bool(value)
532
  return str(value).strip().lower() in ("true", "1", "yes", "y", "checked")
533
 
 
534
  edited_replacements = {}
535
  edited_report_rows = []
 
536
  for _, row in edited_replacements_df.iterrows():
537
  include = safe_bool(row.get("include", False))
538
  find_text = safe_cell(row.get("find", ""))
539
  replace_text = safe_cell(row.get("replace_with", ""))
540
  entity_type = safe_cell(row.get("entity_type", "MANUAL")) or "MANUAL"
541
  score = row.get("score", None)
542
+ if not include or not find_text or not replace_text:
 
 
 
 
543
  continue
 
544
  edited_replacements[find_text] = replace_text
 
545
  edited_report_rows.append(
546
  {
547
  "entity_type": entity_type,
 
553
 
554
  st.info(f"{len(edited_replacements)} replacement pair(s) will be applied to the exports.")
555
 
 
556
  export_text = apply_replacements_to_text(st_text, edited_replacements)
 
557
  with st.expander("Preview anonymized text generated from edited table", expanded=False):
558
+ st.text_area(label="Preview", value=export_text, height=300, key="edited_export_preview")
 
 
 
 
 
559
 
560
  st.subheader("Remember reusable replacements")
 
561
  remember_rows_to_save = []
 
562
  for _, row in edited_replacements_df.iterrows():
563
  include = safe_bool(row.get("include", False))
564
  remember = safe_bool(row.get("remember", False))
565
  find_text = safe_cell(row.get("find", ""))
566
  replace_text = safe_cell(row.get("replace_with", ""))
567
  entity_type = safe_cell(row.get("entity_type", "REMEMBERED")) or "REMEMBERED"
 
568
  if include and remember and find_text and replace_text:
569
  remember_rows_to_save.append(
570
+ {"find": find_text, "replace_with": replace_text, "entity_type": entity_type}
 
 
 
 
571
  )
572
 
573
  memory_col1, memory_col2 = st.columns(2)
 
574
  with memory_col1:
575
  if st.button("Save remembered replacements"):
576
  saved_count = save_remembered_replacements(remember_rows_to_save)
577
  st.success(f"Saved {saved_count} remembered replacement pair(s).")
578
  st.info(f"Memory file: {get_memory_file_path()}")
 
579
  with memory_col2:
580
  if st.button("Clear remembered replacements"):
581
  clear_remembered_replacements()
582
  st.warning("Remembered replacements cleared.")
583
 
584
  st.subheader("Export anonymized files")
 
585
  if uploaded_file is not None:
586
  st.info(f"Uploaded file detected for export: {uploaded_file.name}")
587
  else:
588
  st.info("No uploaded file detected for export. Exporting from text area only.")
589
 
 
590
  st.download_button(
591
  label="Download anonymized text (.txt)",
592
  data=export_text.encode("utf-8"),
 
594
  mime="text/plain",
595
  key="download_txt",
596
  )
 
 
597
  st.download_button(
598
  label="Download replacement table (.csv)",
599
  data=replacement_report_csv(edited_report_rows),
 
601
  mime="text/csv",
602
  key="download_csv",
603
  )
604
+ st.download_button(
605
+ label="Download scrub report (.txt)",
606
+ data=scrub_report_txt(
607
+ edited_report_rows,
608
+ profile=st_recognition_profile,
609
+ source_filename=uploaded_file.name if uploaded_file is not None else None,
610
+ ),
611
+ file_name="scrub_report.txt",
612
+ mime="text/plain",
613
+ key="download_scrub_report",
614
+ )
615
 
 
616
  try:
617
  if uploaded_file is not None and uploaded_file.name.lower().endswith(".docx"):
618
  docx_bytes = anonymized_docx_from_original(uploaded_file, edited_replacements)
 
620
  else:
621
  docx_bytes = docx_from_text(export_text)
622
  docx_filename = "anonymized_text.docx"
 
623
  st.download_button(
624
  label="Download anonymized Word file (.docx)",
625
  data=docx_bytes,
 
627
  mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
628
  key="download_docx",
629
  )
 
630
  except Exception as docx_error:
631
  st.error(f"Could not create DOCX export: {docx_error}")
632
 
 
633
  try:
634
  st.download_button(
635
  label="Download anonymized PDF (.pdf)",
 
638
  mime="application/pdf",
639
  key="download_pdf",
640
  )
 
641
  except Exception as pdf_error:
642
  st.error(f"Could not create PDF export: {pdf_error}")
643
 
644
  elif st_operator == "synthesize":
645
  with col2:
646
+ st.subheader("OpenAI Generated output")
647
+ fake_data = create_fake_data(st_text, st_analyze_results, open_ai_params)
 
 
 
 
648
  st.text_area(label="Synthetic data", value=fake_data, height=400)
649
  else:
650
  st.subheader("Highlighted")
651
  annotated_tokens = annotate(text=st_text, analyze_results=st_analyze_results)
 
652
  annotated_text(*annotated_tokens)
653
 
654
+ st.subheader("Findings" if not st_return_decision_process else "Findings with decision factors")
 
 
 
 
 
655
  if st_analyze_results:
656
  df = pd.DataFrame.from_records([r.to_dict() for r in st_analyze_results])
657
  df["text"] = [st_text[res.start : res.end] for res in st_analyze_results]
 
658
  df_subset = df[["entity_type", "text", "start", "end", "score"]].rename(
659
  {
660
  "entity_type": "Entity type",
 
665
  },
666
  axis=1,
667
  )
 
668
  if st_return_decision_process:
669
  analysis_explanation_df = pd.DataFrame.from_records(
670
  [r.analysis_explanation.to_dict() for r in st_analyze_results]
 
679
  traceback.print_exc()
680
  st.error(e)
681
 
682
+ components.html(""" """)