File size: 13,159 Bytes
53a355d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7fa75e4
 
 
 
 
 
 
 
 
 
 
 
 
 
53a355d
7fa75e4
 
53a355d
 
7fa75e4
 
53a355d
 
 
7fa75e4
 
53a355d
 
c9dcf06
 
 
 
 
 
 
 
 
 
53a355d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7fa75e4
 
 
 
c9dcf06
53a355d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7fa75e4
53a355d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9dcf06
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53a355d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9dcf06
53a355d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
"""Candidate scanner for SolidPrivacy Scrub Dutch Legal Strict mode.

This module is deliberately a review/audit layer, not an automatic redaction
layer. It looks for suspicious reference-like values that were not already found
by Presidio recognizers. The UI can show them as unchecked rows in the editable
replacement table, so the user can decide whether to include them.

Design rules:
- Preserve context words such as dossiernummer, kenteken, factuurnummer.
- Suggest only the suspicious value, not the whole sentence.
- Do not suggest legal article references, dates, money amounts, pages or annexes.
- Prefer category-level review over one-off hotfixes.
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Iterable, List, Sequence, Tuple

try:
    from legal_reference_taxonomy import LEGAL_REFERENCE_CATEGORIES
except Exception:  # keep the app usable while files are being copied
    LEGAL_REFERENCE_CATEGORIES = []


@dataclass(frozen=True)
class Candidate:
    text: str
    entity_type: str
    placeholder: str
    score: float
    start: int
    end: int
    reason: str
    context: str

    def as_dict(self) -> dict:
        return {
            "text": self.text,
            "entity_type": self.entity_type,
            "placeholder": self.placeholder,
            "score": self.score,
            "start": self.start,
            "end": self.end,
            "reason": self.reason,
            "context": self.context,
        }


# Broad value shapes used only after a context cue has been found.
# This catches values like CL-FAM-55201, WR-KLANT-2026-7712, FACT-2026-4481,
# DOSS/2026/1189 and compact context-bound values such as XX123X after "kenteken".
CASE_NUMBER_VALUE_RE_PART = (
    r"(?:"
    r"C/\d{2}/\d{5,6}\s*/\s*(?:[A-Z]{1,5}\s+){1,4}\d{2}[-/]\d{1,6}"
    r"|\d{6,9}\s*/\s*(?:[A-Z]{1,5}\s+){1,4}\d{2}[-/]\d{1,6}"
    r"|[A-Z]{2,5}\s+\d{2}/\d{1,6}"
    r"|NL\d{2}\.\d{3,8}"
    r"|\d{3}\.\d{3}\.\d{3}/\d{2}\s+[A-Z]{1,5}"
    r")"
)

GENERIC_CONTEXTUAL_VALUE_RE_PART = (
    r"(?:"
    r"(?=[A-Z0-9][A-Z0-9./_-]{4,59}\b)(?=[A-Z0-9./_-]*[A-Z])(?=[A-Z0-9./_-]*\d)"
    r"[A-Z0-9]+(?:[./_-][A-Z0-9]+){0,10}"
    r"|"
    r"(?=[A-Z0-9]{5,12}\b)(?=[A-Z0-9]*[A-Z])(?=[A-Z0-9]*\d)[A-Z0-9]{5,12}"
    r")"
)

CONTEXTUAL_VALUE_RE = re.compile(r"\b(?:" + CASE_NUMBER_VALUE_RE_PART + r"|" + GENERIC_CONTEXTUAL_VALUE_RE_PART + r")\b")

# Stand-alone suspicious codes. These are shown as candidates only when they are
# not already detected and not obviously a date/article/amount.
STANDALONE_CODE_RE = re.compile(
    r"\b(?=[A-Z0-9][A-Z0-9./_-]{5,59}\b)(?=[A-Z0-9./_-]*[A-Z])(?=[A-Z0-9./_-]*\d)"
    r"[A-Z0-9]+(?:[./_-][A-Z0-9]+){1,10}\b"
)

# Pure lightweight fallback for KvK numbers. The main recognizer stack also has
# an NL_KVK_NUMBER recognizer, but this keeps the audit/candidate layer useful if
# the automatic recognizer misses a labelled 8-digit value.
KVK_LABELLED_VALUE_RE = re.compile(
    r"\b(?:kvk(?:[-\s]?nummer|\s?nr\.)?|kamer\s+van\s+koophandel|handelsregister)"
    r"(?:[ \t]+(?:vennootschap|bedrijf|organisatie|rechtspersoon|vereniging|stichting))?"
    r"\s*(?:is|:|#|-)?\s*(?P<value>\d{8})\b",
    flags=re.IGNORECASE | re.MULTILINE,
)

DUTCH_PLATE_CONTEXT = {
    "kenteken",
    "kentekennummer",
    "nummerplaat",
    "voertuig",
    "auto",
    "leaseauto",
    "bedrijfsauto",
    "bestelbus",
    "rdw",
}

GENERIC_CONTEXT_CUES = {
    "nummer",
    "referentie",
    "kenmerk",
    "dossier",
    "code",
    "registratie",
    "zaak",
    "factuur",
    "contract",
    "polis",
    "claim",
    "school",
    "uwv",
    "ind",
    "gemeente",
    "politie",
    "proces-verbaal",
    "pv",
}

NEGATIVE_NEARBY_CUES = {
    "artikel",
    "art.",
    "lid",
    "sub",
    "pagina",
    "bladzijde",
    "bijlage",
    "productie",
    "randnummer",
    "paragraaf",
}


def _overlaps(start: int, end: int, spans: Sequence[Tuple[int, int]]) -> bool:
    for other_start, other_end in spans:
        if start < other_end and end > other_start:
            return True
    return False


def _window(text: str, start: int, end: int, radius: int = 60) -> str:
    return text[max(0, start - radius) : min(len(text), end + radius)]


def _normalise_space(value: str) -> str:
    return re.sub(r"\s+", " ", value or "").strip()


def _looks_like_date_or_time(value: str) -> bool:
    v = (value or "").strip()
    if re.fullmatch(r"\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4}", v):
        return True
    if re.fullmatch(r"\d{4}[-/.]\d{1,2}[-/.]\d{1,2}", v):
        return True
    if re.fullmatch(r"\d{1,2}[.:]\d{2}", v):
        return True
    return False


def _looks_like_money_or_article(value: str, context: str) -> bool:
    low = (context or "").lower()
    v = (value or "").strip()
    if "€" in low or "eur" in low or "euro" in low:
        return True
    if re.fullmatch(r"\d{1,2}:\d{1,4}[a-z]?", v, flags=re.IGNORECASE):
        return True
    # Avoid legal article references and document navigation references.
    if any(cue in low for cue in NEGATIVE_NEARBY_CUES) and not any(cue in low for cue in GENERIC_CONTEXT_CUES):
        return True
    return False


def _is_negative_candidate(value: str, context: str) -> bool:
    v = (value or "").strip(" .,;:\n\t")
    if len(v) < 5:
        return True
    if _looks_like_date_or_time(v) or _looks_like_money_or_article(v, context):
        return True
    # Plain Dutch postcode is already handled by NL_POSTCODE; do not duplicate it
    # as a suspicious reference.
    if re.fullmatch(r"[1-9][0-9]{3}\s?[A-Z]{2}", v, flags=re.IGNORECASE):
        return True
    return False


def _keyword_regex(keyword: str) -> re.Pattern:
    escaped = re.escape(keyword.strip()).replace(r"\ ", r"[ \t]+")
    return re.compile(rf"(?<!\w){escaped}(?!\w)", flags=re.IGNORECASE | re.MULTILINE)


def _search_boundary(text: str, start: int, max_chars: int = 120) -> int:
    hard_end = min(len(text), start + max_chars)
    candidates = [hard_end]
    for sep in ["\n", "\r", ";"]:
        idx = text.find(sep, start, hard_end)
        if idx != -1:
            candidates.append(idx)
    dot = text.find(".", start, hard_end)
    if dot != -1 and dot - start > 25:
        candidates.append(dot)
    return min(candidates)


def _placeholder_for(entity_type: str) -> str:
    labels = {
        "NL_SUSPICIOUS_REFERENCE_CANDIDATE": "<MOGELIJKE_REFERENTIE>",
        "NL_POSSIBLE_LICENSE_PLATE": "<MOGELIJK_KENTEKEN>",
        "NL_VEHICLE_REFERENCE": "<VOERTUIG_OF_KENTEKENREFERENTIE>",
        "NL_OBJECT_REFERENCE": "<OBJECTREFERENTIE>",
        "NL_INCIDENT_NUMBER": "<INCIDENTNUMMER>",
        "NL_CLAIM_NUMBER": "<CLAIMNUMMER>",
        "NL_OTHER_REFERENCE": "<OVERIGE_REFERENTIE>",
        "NL_LEGAL_CASE_NUMBER": "<ZAAKNUMMER>",
        "NL_KVK_NUMBER": "<KVK_NUMMER>",
    }
    for category in LEGAL_REFERENCE_CATEGORIES:
        if category.get("entity_type") == entity_type:
            return f"<{category.get('placeholder', entity_type)}>"
    return labels.get(entity_type, f"<{entity_type}>")


def _has_context_cue(context: str) -> bool:
    low = (context or "").lower()
    return any(cue in low for cue in GENERIC_CONTEXT_CUES) or any(cue in low for cue in DUTCH_PLATE_CONTEXT)


def _dedupe(candidates: Iterable[Candidate]) -> List[Candidate]:
    by_span = {}
    for candidate in candidates:
        key = (candidate.start, candidate.end, candidate.text)
        existing = by_span.get(key)
        if existing is None or candidate.score > existing.score:
            by_span[key] = candidate
    return sorted(by_span.values(), key=lambda item: (item.start, -item.score))


def scan_unmasked_candidates(text: str, analyzer_results=None, max_candidates: int = 50) -> List[dict]:
    """Return suspicious unmasked candidate values for review.

    analyzer_results may be Presidio RecognizerResult objects. Their spans are
    excluded so this scanner focuses on what likely remained unhandled.
    """
    source = text or ""
    existing_spans = []
    for res in analyzer_results or []:
        start = getattr(res, "start", None)
        end = getattr(res, "end", None)
        if isinstance(start, int) and isinstance(end, int):
            existing_spans.append((start, end))

    candidates: List[Candidate] = []

    # 1) Taxonomy-driven contextual values that were not detected. This uses the
    # same categories as the recognizer but keeps them as unchecked candidates in
    # case thresholds/entity filters missed them.
    for category in LEGAL_REFERENCE_CATEGORIES:
        entity_type = category.get("entity_type", "NL_CONTEXTUAL_REFERENCE")
        keywords = category.get("keywords", [])
        base_score = max(0.50, float(category.get("score", 0.70)) - 0.10)
        for keyword in keywords:
            for kw_match in _keyword_regex(keyword).finditer(source):
                search_start = kw_match.end()
                search_end = _search_boundary(source, search_start)
                local_text = source[search_start:search_end]
                value_match = CONTEXTUAL_VALUE_RE.search(local_text)
                if not value_match:
                    continue
                start = search_start + value_match.start()
                end = search_start + value_match.end()
                raw = source[start:end]
                trim_l = len(raw) - len(raw.lstrip(" \t:=-#"))
                trim_r = len(raw) - len(raw.rstrip(" \t.,;:"))
                start += trim_l
                if trim_r:
                    end -= trim_r
                value = source[start:end]
                ctx = _window(source, start, end)
                if _overlaps(start, end, existing_spans) or _is_negative_candidate(value, ctx):
                    continue
                candidates.append(
                    Candidate(
                        text=value,
                        entity_type=entity_type,
                        placeholder=_placeholder_for(entity_type),
                        score=base_score,
                        start=start,
                        end=end,
                        reason=f"Possible unmasked value after context keyword '{keyword}'",
                        context=_normalise_space(ctx),
                    )
                )

    # 2) Structured labelled KvK values. The value is numeric-only, so it needs a
    # dedicated context-bound fallback instead of the generic uppercase code logic.
    for match in KVK_LABELLED_VALUE_RE.finditer(source):
        start, end = match.span("value")
        value = source[start:end]
        ctx = _window(source, start, end)
        if _overlaps(start, end, existing_spans):
            continue
        candidates.append(
            Candidate(
                text=value,
                entity_type="NL_KVK_NUMBER",
                placeholder=_placeholder_for("NL_KVK_NUMBER"),
                score=0.78,
                start=start,
                end=end,
                reason="Eight-digit value after KvK/handelsregister context",
                context=_normalise_space(ctx),
            )
        )

    # 3) License plate / vehicle compact candidates. These are context-bound, not
    # blind plate recognition, because fake/test material often uses compact
    # examples such as XX123X.
    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):
        start, end = match.span()
        value = match.group(0)
        ctx = _window(source, start, end)
        if _overlaps(start, end, existing_spans) or _is_negative_candidate(value, ctx):
            continue
        if any(cue in ctx.lower() for cue in DUTCH_PLATE_CONTEXT):
            candidates.append(
                Candidate(
                    text=value,
                    entity_type="NL_POSSIBLE_LICENSE_PLATE",
                    placeholder=_placeholder_for("NL_POSSIBLE_LICENSE_PLATE"),
                    score=0.66,
                    start=start,
                    end=end,
                    reason="Compact alphanumeric value near vehicle/kenteken context",
                    context=_normalise_space(ctx),
                )
            )

    # 4) Remaining standalone codes with generic legal/admin context nearby.
    for match in STANDALONE_CODE_RE.finditer(source):
        start, end = match.span()
        value = match.group(0)
        ctx = _window(source, start, end)
        if _overlaps(start, end, existing_spans) or _is_negative_candidate(value, ctx):
            continue
        if not _has_context_cue(ctx):
            continue
        candidates.append(
            Candidate(
                text=value,
                entity_type="NL_SUSPICIOUS_REFERENCE_CANDIDATE",
                placeholder=_placeholder_for("NL_SUSPICIOUS_REFERENCE_CANDIDATE"),
                score=0.52,
                start=start,
                end=end,
                reason="Reference-like code near legal/administrative context but not auto-masked",
                context=_normalise_space(ctx),
            )
        )

    return [candidate.as_dict() for candidate in _dedupe(candidates)[:max_candidates]]