solidprivacy commited on
Commit
5beca75
·
verified ·
1 Parent(s): a1b646b

Improve Dutch placeholder generation

Browse files
Files changed (1) hide show
  1. document_tools.py +160 -31
document_tools.py CHANGED
@@ -1,6 +1,7 @@
1
  from io import BytesIO, StringIO
2
  from html import escape
3
  import csv
 
4
 
5
  import fitz # PyMuPDF
6
  from docx import Document
@@ -9,10 +10,108 @@ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
9
  from reportlab.lib.styles import getSampleStyleSheet
10
 
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  def uploaded_file_to_text(uploaded_file):
13
- """
14
- Convert uploaded .txt, .docx, or text-based .pdf to plain text.
15
- """
16
  filename = uploaded_file.name.lower()
17
  data = uploaded_file.getvalue()
18
 
@@ -53,10 +152,8 @@ def extract_pdf_text(pdf_bytes):
53
 
54
 
55
  def iter_docx_paragraphs(doc):
56
- """
57
- Yield paragraphs from body, tables, headers and footers.
58
- This does not cover every exotic Word object, but it is a good MVP.
59
- """
60
  for paragraph in doc.paragraphs:
61
  yield paragraph
62
 
@@ -89,14 +186,52 @@ def iter_table_paragraphs(table):
89
  yield from iter_table_paragraphs(nested_table)
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  def build_placeholder_replacements(text, analyze_results):
93
- """
94
- Build stable placeholders from Presidio results.
95
 
96
- Example:
97
- John Smith -> [PERSON_01]
98
- Rotterdam -> [LOCATION_01]
99
- """
100
  counters = {}
101
  replacements = {}
102
  report_rows = []
@@ -104,17 +239,18 @@ def build_placeholder_replacements(text, analyze_results):
104
  sorted_results = sorted(analyze_results, key=lambda r: (r.start, r.end))
105
 
106
  for result in sorted_results:
107
- original = text[result.start:result.end].strip()
 
 
108
 
109
- if not original:
110
  continue
111
 
112
  if original in replacements:
113
  continue
114
 
115
- entity_type = result.entity_type
116
  counters[entity_type] = counters.get(entity_type, 0) + 1
117
- placeholder = f"[{entity_type}_{counters[entity_type]:02d}]"
118
 
119
  replacements[original] = placeholder
120
 
@@ -123,7 +259,7 @@ def build_placeholder_replacements(text, analyze_results):
123
  "entity_type": entity_type,
124
  "detected_text": original,
125
  "placeholder": placeholder,
126
- "score": round(float(result.score), 3),
127
  }
128
  )
129
 
@@ -131,9 +267,8 @@ def build_placeholder_replacements(text, analyze_results):
131
 
132
 
133
  def apply_replacements_to_text(text, replacements):
134
- """
135
- Apply longest replacements first to avoid partial replacement problems.
136
- """
137
  output = text
138
 
139
  for original, placeholder in sorted(
@@ -145,10 +280,8 @@ def apply_replacements_to_text(text, replacements):
145
 
146
 
147
  def anonymized_docx_from_original(uploaded_file, replacements):
148
- """
149
- Open original .docx and replace detected text inside existing paragraphs/runs.
150
- This preserves most normal Word formatting, but not all complex Word features.
151
- """
152
  doc = Document(BytesIO(uploaded_file.getvalue()))
153
 
154
  for paragraph in iter_docx_paragraphs(doc):
@@ -169,12 +302,8 @@ def replace_in_paragraph_runs(paragraph, replacements):
169
 
170
 
171
  def replace_once_in_runs(paragraph, old_text, new_text):
172
- """
173
- Replace one occurrence across Word runs.
174
 
175
- Word splits text into runs for formatting. This method tries to replace
176
- text without rebuilding the whole paragraph.
177
- """
178
  if not paragraph.runs:
179
  return False
180
 
@@ -259,4 +388,4 @@ def replacement_report_csv(report_rows):
259
  for row in report_rows:
260
  writer.writerow(row)
261
 
262
- return output.getvalue().encode("utf-8")
 
1
  from io import BytesIO, StringIO
2
  from html import escape
3
  import csv
4
+ import re
5
 
6
  import fitz # PyMuPDF
7
  from docx import Document
 
10
  from reportlab.lib.styles import getSampleStyleSheet
11
 
12
 
13
+ PLACEHOLDER_LABELS = {
14
+ "PERSON": "PERSOON",
15
+ "LOCATION": "LOCATIE",
16
+ "ORGANIZATION": "ORGANISATIE",
17
+ "EMAIL_ADDRESS": "EMAIL",
18
+ "PHONE_NUMBER": "TELEFOON",
19
+ "IBAN_CODE": "IBAN",
20
+ "CREDIT_CARD": "BETAALKAART",
21
+ "DATE_TIME": "DATUM",
22
+ "URL": "URL",
23
+ "IP_ADDRESS": "IP_ADRES",
24
+ "NL_BSN": "BSN",
25
+ "NL_POSTCODE": "POSTCODE",
26
+ "NL_IBAN": "IBAN",
27
+ "NL_KVK_NUMBER": "KVK_NUMMER",
28
+ "NL_VAT_NUMBER": "BTW_NUMMER",
29
+ "NL_PHONE_NUMBER": "TELEFOON",
30
+ "NL_LICENSE_PLATE": "KENTEKEN",
31
+ "NL_DRIVER_LICENSE": "RIJBEWIJS",
32
+ "NL_BIG_NUMBER": "BIG_NUMMER",
33
+ "GENERIC_PII": "VERTROUWELIJK",
34
+ }
35
+
36
+ STRUCTURED_ENTITY_TYPES = {
37
+ "EMAIL_ADDRESS",
38
+ "PHONE_NUMBER",
39
+ "IBAN_CODE",
40
+ "CREDIT_CARD",
41
+ "DATE_TIME",
42
+ "URL",
43
+ "IP_ADDRESS",
44
+ "NL_BSN",
45
+ "NL_POSTCODE",
46
+ "NL_IBAN",
47
+ "NL_KVK_NUMBER",
48
+ "NL_VAT_NUMBER",
49
+ "NL_PHONE_NUMBER",
50
+ "NL_LICENSE_PLATE",
51
+ "NL_DRIVER_LICENSE",
52
+ "NL_BIG_NUMBER",
53
+ }
54
+
55
+ # Common headings and document-structure words that should not become global replacements.
56
+ DOCUMENT_WORD_DENYLIST = {
57
+ "chapter",
58
+ "section",
59
+ "article",
60
+ "paragraph",
61
+ "appendix",
62
+ "annex",
63
+ "schedule",
64
+ "table",
65
+ "figure",
66
+ "introduction",
67
+ "background",
68
+ "summary",
69
+ "conclusion",
70
+ "scope",
71
+ "purpose",
72
+ "definitions",
73
+ "agreement",
74
+ "contract",
75
+ "party",
76
+ "parties",
77
+ "client",
78
+ "supplier",
79
+ "service",
80
+ "services",
81
+ "project",
82
+ "document",
83
+ "version",
84
+ "draft",
85
+ "review",
86
+ "confidential",
87
+ "hoofdstuk",
88
+ "paragraaf",
89
+ "artikel",
90
+ "bijlage",
91
+ "inleiding",
92
+ "samenvatting",
93
+ "conclusie",
94
+ "doel",
95
+ "definities",
96
+ "overeenkomst",
97
+ "contract",
98
+ "partij",
99
+ "partijen",
100
+ "klant",
101
+ "leverancier",
102
+ "dienst",
103
+ "diensten",
104
+ "project",
105
+ "document",
106
+ "versie",
107
+ "concept",
108
+ "vertrouwelijk",
109
+ }
110
+
111
+
112
  def uploaded_file_to_text(uploaded_file):
113
+ """Convert uploaded .txt, .docx, or text-based .pdf to plain text."""
114
+
 
115
  filename = uploaded_file.name.lower()
116
  data = uploaded_file.getvalue()
117
 
 
152
 
153
 
154
  def iter_docx_paragraphs(doc):
155
+ """Yield paragraphs from body, tables, headers and footers."""
156
+
 
 
157
  for paragraph in doc.paragraphs:
158
  yield paragraph
159
 
 
186
  yield from iter_table_paragraphs(nested_table)
187
 
188
 
189
+ def normalize_detected_text(value: str) -> str:
190
+ return re.sub(r"\s+", " ", (value or "").strip())
191
+
192
+
193
+ def should_skip_detection(original: str, entity_type: str, score: float) -> bool:
194
+ """Reduce false-positive global replacements in exported documents."""
195
+
196
+ if not original:
197
+ return True
198
+
199
+ original_clean = normalize_detected_text(original)
200
+ original_lower = original_clean.lower()
201
+
202
+ if original_lower in DOCUMENT_WORD_DENYLIST:
203
+ return True
204
+
205
+ if not any(char.isalnum() for char in original_clean):
206
+ return True
207
+
208
+ if original_clean.isdigit() and entity_type not in STRUCTURED_ENTITY_TYPES:
209
+ return True
210
+
211
+ if len(original_clean) < 4 and entity_type not in STRUCTURED_ENTITY_TYPES:
212
+ return True
213
+
214
+ # Organization detection is useful, but often false-positives on headings.
215
+ if entity_type == "ORGANIZATION":
216
+ if score < 0.85:
217
+ return True
218
+ if len(original_clean.split()) == 1 and "." not in original_clean:
219
+ return True
220
+
221
+ if entity_type == "LOCATION" and score < 0.60:
222
+ return True
223
+
224
+ return False
225
+
226
+
227
+ def placeholder_for_entity(entity_type: str, count: int) -> str:
228
+ label = PLACEHOLDER_LABELS.get(entity_type, entity_type)
229
+ return f"[{label}_{count:02d}]"
230
+
231
+
232
  def build_placeholder_replacements(text, analyze_results):
233
+ """Build stable placeholder suggestions from Presidio results."""
 
234
 
 
 
 
 
235
  counters = {}
236
  replacements = {}
237
  report_rows = []
 
239
  sorted_results = sorted(analyze_results, key=lambda r: (r.start, r.end))
240
 
241
  for result in sorted_results:
242
+ original = normalize_detected_text(text[result.start : result.end])
243
+ entity_type = result.entity_type
244
+ score = float(getattr(result, "score", 0.0) or 0.0)
245
 
246
+ if should_skip_detection(original, entity_type, score):
247
  continue
248
 
249
  if original in replacements:
250
  continue
251
 
 
252
  counters[entity_type] = counters.get(entity_type, 0) + 1
253
+ placeholder = placeholder_for_entity(entity_type, counters[entity_type])
254
 
255
  replacements[original] = placeholder
256
 
 
259
  "entity_type": entity_type,
260
  "detected_text": original,
261
  "placeholder": placeholder,
262
+ "score": round(score, 3),
263
  }
264
  )
265
 
 
267
 
268
 
269
  def apply_replacements_to_text(text, replacements):
270
+ """Apply longest replacements first to avoid partial replacement problems."""
271
+
 
272
  output = text
273
 
274
  for original, placeholder in sorted(
 
280
 
281
 
282
  def anonymized_docx_from_original(uploaded_file, replacements):
283
+ """Replace detected text inside the original .docx, preserving most formatting."""
284
+
 
 
285
  doc = Document(BytesIO(uploaded_file.getvalue()))
286
 
287
  for paragraph in iter_docx_paragraphs(doc):
 
302
 
303
 
304
  def replace_once_in_runs(paragraph, old_text, new_text):
305
+ """Replace one occurrence across Word runs."""
 
306
 
 
 
 
307
  if not paragraph.runs:
308
  return False
309
 
 
388
  for row in report_rows:
389
  writer.writerow(row)
390
 
391
+ return output.getvalue().encode("utf-8")