solidprivacy commited on
Commit
53a355d
·
verified ·
1 Parent(s): fc4e893

Add Dutch legal audit candidate review layer

Browse files
Files changed (1) hide show
  1. candidate_scanner.py +316 -0
candidate_scanner.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Candidate scanner for SolidPrivacy Scrub Dutch Legal Strict mode.
2
+
3
+ This module is deliberately a review/audit layer, not an automatic redaction
4
+ layer. It looks for suspicious reference-like values that were not already found
5
+ by Presidio recognizers. The UI can show them as unchecked rows in the editable
6
+ replacement table, so the user can decide whether to include them.
7
+
8
+ Design rules:
9
+ - Preserve context words such as dossiernummer, kenteken, factuurnummer.
10
+ - Suggest only the suspicious value, not the whole sentence.
11
+ - Do not suggest legal article references, dates, money amounts, pages or annexes.
12
+ - Prefer category-level review over one-off hotfixes.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass
19
+ from typing import Iterable, List, Sequence, Tuple
20
+
21
+ try:
22
+ from legal_reference_taxonomy import LEGAL_REFERENCE_CATEGORIES
23
+ except Exception: # keep the app usable while files are being copied
24
+ LEGAL_REFERENCE_CATEGORIES = []
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Candidate:
29
+ text: str
30
+ entity_type: str
31
+ placeholder: str
32
+ score: float
33
+ start: int
34
+ end: int
35
+ reason: str
36
+ context: str
37
+
38
+ def as_dict(self) -> dict:
39
+ return {
40
+ "text": self.text,
41
+ "entity_type": self.entity_type,
42
+ "placeholder": self.placeholder,
43
+ "score": self.score,
44
+ "start": self.start,
45
+ "end": self.end,
46
+ "reason": self.reason,
47
+ "context": self.context,
48
+ }
49
+
50
+
51
+ # Broad value shapes used only after a context cue has been found.
52
+ # This catches values like CL-FAM-55201, WR-KLANT-2026-7712, FACT-2026-4481,
53
+ # DOSS/2026/1189 and compact context-bound values such as XX123X after "kenteken".
54
+ CONTEXTUAL_VALUE_RE = re.compile(
55
+ r"\b(?:"
56
+ r"(?=[A-Z0-9][A-Z0-9./_-]{4,49}\b)(?=[A-Z0-9./_-]*[A-Z])(?=[A-Z0-9./_-]*\d)"
57
+ r"[A-Z0-9]+(?:[./_-][A-Z0-9]+){0,8}"
58
+ r"|"
59
+ r"\d{3}\.\d{3}\.\d{3}/\d{2}\s+[A-Z]{1,5}"
60
+ r")\b"
61
+ )
62
+
63
+ # Stand-alone suspicious codes. These are shown as candidates only when they are
64
+ # not already detected and not obviously a date/article/amount.
65
+ STANDALONE_CODE_RE = re.compile(
66
+ r"\b(?=[A-Z0-9][A-Z0-9./_-]{5,49}\b)(?=[A-Z0-9./_-]*[A-Z])(?=[A-Z0-9./_-]*\d)"
67
+ r"[A-Z0-9]+(?:[./_-][A-Z0-9]+){1,8}\b"
68
+ )
69
+
70
+ DUTCH_PLATE_CONTEXT = {
71
+ "kenteken",
72
+ "kentekennummer",
73
+ "nummerplaat",
74
+ "voertuig",
75
+ "auto",
76
+ "leaseauto",
77
+ "bedrijfsauto",
78
+ "bestelbus",
79
+ "rdw",
80
+ }
81
+
82
+ GENERIC_CONTEXT_CUES = {
83
+ "nummer",
84
+ "referentie",
85
+ "kenmerk",
86
+ "dossier",
87
+ "code",
88
+ "registratie",
89
+ "zaak",
90
+ "factuur",
91
+ "contract",
92
+ "polis",
93
+ "claim",
94
+ "school",
95
+ "uwv",
96
+ "ind",
97
+ "gemeente",
98
+ "politie",
99
+ "proces-verbaal",
100
+ "pv",
101
+ }
102
+
103
+ NEGATIVE_NEARBY_CUES = {
104
+ "artikel",
105
+ "art.",
106
+ "lid",
107
+ "sub",
108
+ "pagina",
109
+ "bladzijde",
110
+ "bijlage",
111
+ "productie",
112
+ "randnummer",
113
+ "paragraaf",
114
+ }
115
+
116
+
117
+ def _overlaps(start: int, end: int, spans: Sequence[Tuple[int, int]]) -> bool:
118
+ for other_start, other_end in spans:
119
+ if start < other_end and end > other_start:
120
+ return True
121
+ return False
122
+
123
+
124
+ def _window(text: str, start: int, end: int, radius: int = 60) -> str:
125
+ return text[max(0, start - radius) : min(len(text), end + radius)]
126
+
127
+
128
+ def _normalise_space(value: str) -> str:
129
+ return re.sub(r"\s+", " ", value or "").strip()
130
+
131
+
132
+ def _looks_like_date_or_time(value: str) -> bool:
133
+ v = (value or "").strip()
134
+ if re.fullmatch(r"\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4}", v):
135
+ return True
136
+ if re.fullmatch(r"\d{4}[-/.]\d{1,2}[-/.]\d{1,2}", v):
137
+ return True
138
+ if re.fullmatch(r"\d{1,2}[.:]\d{2}", v):
139
+ return True
140
+ return False
141
+
142
+
143
+ def _looks_like_money_or_article(value: str, context: str) -> bool:
144
+ low = (context or "").lower()
145
+ v = (value or "").strip()
146
+ if "€" in low or "eur" in low or "euro" in low:
147
+ return True
148
+ if re.fullmatch(r"\d{1,2}:\d{1,4}[a-z]?", v, flags=re.IGNORECASE):
149
+ return True
150
+ # Avoid legal article references and document navigation references.
151
+ if any(cue in low for cue in NEGATIVE_NEARBY_CUES) and not any(cue in low for cue in GENERIC_CONTEXT_CUES):
152
+ return True
153
+ return False
154
+
155
+
156
+ def _is_negative_candidate(value: str, context: str) -> bool:
157
+ v = (value or "").strip(" .,;:\n\t")
158
+ if len(v) < 5:
159
+ return True
160
+ if _looks_like_date_or_time(v) or _looks_like_money_or_article(v, context):
161
+ return True
162
+ # Plain Dutch postcode is already handled by NL_POSTCODE; do not duplicate it
163
+ # as a suspicious reference.
164
+ if re.fullmatch(r"[1-9][0-9]{3}\s?[A-Z]{2}", v, flags=re.IGNORECASE):
165
+ return True
166
+ return False
167
+
168
+
169
+ def _keyword_regex(keyword: str) -> re.Pattern:
170
+ escaped = re.escape(keyword.strip()).replace(r"\ ", r"[ \t]+")
171
+ return re.compile(rf"(?<!\w){escaped}(?!\w)", flags=re.IGNORECASE | re.MULTILINE)
172
+
173
+
174
+ def _search_boundary(text: str, start: int, max_chars: int = 120) -> int:
175
+ hard_end = min(len(text), start + max_chars)
176
+ candidates = [hard_end]
177
+ for sep in ["\n", "\r", ";"]:
178
+ idx = text.find(sep, start, hard_end)
179
+ if idx != -1:
180
+ candidates.append(idx)
181
+ dot = text.find(".", start, hard_end)
182
+ if dot != -1 and dot - start > 25:
183
+ candidates.append(dot)
184
+ return min(candidates)
185
+
186
+
187
+ def _placeholder_for(entity_type: str) -> str:
188
+ labels = {
189
+ "NL_SUSPICIOUS_REFERENCE_CANDIDATE": "<MOGELIJKE_REFERENTIE>",
190
+ "NL_POSSIBLE_LICENSE_PLATE": "<MOGELIJK_KENTEKEN>",
191
+ "NL_VEHICLE_REFERENCE": "<VOERTUIG_OF_KENTEKENREFERENTIE>",
192
+ "NL_OBJECT_REFERENCE": "<OBJECTREFERENTIE>",
193
+ }
194
+ for category in LEGAL_REFERENCE_CATEGORIES:
195
+ if category.get("entity_type") == entity_type:
196
+ return f"<{category.get('placeholder', entity_type)}>"
197
+ return labels.get(entity_type, f"<{entity_type}>")
198
+
199
+
200
+ def _has_context_cue(context: str) -> bool:
201
+ low = (context or "").lower()
202
+ return any(cue in low for cue in GENERIC_CONTEXT_CUES) or any(cue in low for cue in DUTCH_PLATE_CONTEXT)
203
+
204
+
205
+ def _dedupe(candidates: Iterable[Candidate]) -> List[Candidate]:
206
+ by_span = {}
207
+ for candidate in candidates:
208
+ key = (candidate.start, candidate.end, candidate.text)
209
+ existing = by_span.get(key)
210
+ if existing is None or candidate.score > existing.score:
211
+ by_span[key] = candidate
212
+ return sorted(by_span.values(), key=lambda item: (item.start, -item.score))
213
+
214
+
215
+ def scan_unmasked_candidates(text: str, analyzer_results=None, max_candidates: int = 50) -> List[dict]:
216
+ """Return suspicious unmasked candidate values for review.
217
+
218
+ analyzer_results may be Presidio RecognizerResult objects. Their spans are
219
+ excluded so this scanner focuses on what likely remained unhandled.
220
+ """
221
+ source = text or ""
222
+ existing_spans = []
223
+ for res in analyzer_results or []:
224
+ start = getattr(res, "start", None)
225
+ end = getattr(res, "end", None)
226
+ if isinstance(start, int) and isinstance(end, int):
227
+ existing_spans.append((start, end))
228
+
229
+ candidates: List[Candidate] = []
230
+
231
+ # 1) Taxonomy-driven contextual values that were not detected. This uses the
232
+ # same categories as the recognizer but keeps them as unchecked candidates in
233
+ # case thresholds/entity filters missed them.
234
+ for category in LEGAL_REFERENCE_CATEGORIES:
235
+ entity_type = category.get("entity_type", "NL_CONTEXTUAL_REFERENCE")
236
+ keywords = category.get("keywords", [])
237
+ base_score = min(float(category.get("score", 0.70)), 0.74)
238
+ for keyword in keywords:
239
+ for kw_match in _keyword_regex(keyword).finditer(source):
240
+ search_start = kw_match.end()
241
+ search_end = _search_boundary(source, search_start)
242
+ local_text = source[search_start:search_end]
243
+ value_match = CONTEXTUAL_VALUE_RE.search(local_text)
244
+ if not value_match:
245
+ continue
246
+ start = search_start + value_match.start()
247
+ end = search_start + value_match.end()
248
+ raw = source[start:end]
249
+ trim_l = len(raw) - len(raw.lstrip(" \t:=-#"))
250
+ trim_r = len(raw) - len(raw.rstrip(" \t.,;:"))
251
+ start += trim_l
252
+ if trim_r:
253
+ end -= trim_r
254
+ value = source[start:end]
255
+ ctx = _window(source, start, end)
256
+ if _overlaps(start, end, existing_spans) or _is_negative_candidate(value, ctx):
257
+ continue
258
+ candidates.append(
259
+ Candidate(
260
+ text=value,
261
+ entity_type=entity_type,
262
+ placeholder=_placeholder_for(entity_type),
263
+ score=base_score,
264
+ start=start,
265
+ end=end,
266
+ reason=f"Possible unmasked value after context keyword '{keyword}'",
267
+ context=_normalise_space(ctx),
268
+ )
269
+ )
270
+
271
+ # 2) License plate / vehicle compact candidates. These are context-bound, not
272
+ # blind plate recognition, because fake/test material often uses compact
273
+ # examples such as XX123X.
274
+ for match in re.finditer(r"\b(?=[A-Z0-9]{5,12}\b)(?=[A-Z0-9]*[A-Z])(?=[A-Z0-9]*\d)[A-Z0-9]{5,12}\b", source):
275
+ start, end = match.span()
276
+ value = match.group(0)
277
+ ctx = _window(source, start, end)
278
+ if _overlaps(start, end, existing_spans) or _is_negative_candidate(value, ctx):
279
+ continue
280
+ if any(cue in ctx.lower() for cue in DUTCH_PLATE_CONTEXT):
281
+ candidates.append(
282
+ Candidate(
283
+ text=value,
284
+ entity_type="NL_POSSIBLE_LICENSE_PLATE",
285
+ placeholder=_placeholder_for("NL_POSSIBLE_LICENSE_PLATE"),
286
+ score=0.66,
287
+ start=start,
288
+ end=end,
289
+ reason="Compact alphanumeric value near vehicle/kenteken context",
290
+ context=_normalise_space(ctx),
291
+ )
292
+ )
293
+
294
+ # 3) Remaining standalone codes with generic legal/admin context nearby.
295
+ for match in STANDALONE_CODE_RE.finditer(source):
296
+ start, end = match.span()
297
+ value = match.group(0)
298
+ ctx = _window(source, start, end)
299
+ if _overlaps(start, end, existing_spans) or _is_negative_candidate(value, ctx):
300
+ continue
301
+ if not _has_context_cue(ctx):
302
+ continue
303
+ candidates.append(
304
+ Candidate(
305
+ text=value,
306
+ entity_type="NL_SUSPICIOUS_REFERENCE_CANDIDATE",
307
+ placeholder=_placeholder_for("NL_SUSPICIOUS_REFERENCE_CANDIDATE"),
308
+ score=0.52,
309
+ start=start,
310
+ end=end,
311
+ reason="Reference-like code near legal/administrative context but not auto-masked",
312
+ context=_normalise_space(ctx),
313
+ )
314
+ )
315
+
316
+ return [candidate.as_dict() for candidate in _dedupe(candidates)[:max_candidates]]