euredact 0.3.6 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -3
- package/dist/cjs/cache.js +52 -18
- package/dist/cjs/rules/context.js +1 -1
- package/dist/cjs/rules/countries/index.js +14 -1
- package/dist/cjs/rules/cues.js +168 -0
- package/dist/cjs/rules/engine.js +124 -34
- package/dist/cjs/rules/suppressors.js +101 -38
- package/dist/cjs/rules/validators.js +12 -0
- package/dist/cjs/sdk.js +45 -3
- package/dist/cjs/types.js +10 -0
- package/dist/esm/cache.d.ts +5 -1
- package/dist/esm/cache.js +52 -18
- package/dist/esm/rules/context.d.ts +1 -1
- package/dist/esm/rules/context.js +1 -1
- package/dist/esm/rules/countries/index.js +14 -1
- package/dist/esm/rules/cues.d.ts +91 -0
- package/dist/esm/rules/cues.js +163 -0
- package/dist/esm/rules/engine.js +125 -35
- package/dist/esm/rules/suppressors.d.ts +14 -1
- package/dist/esm/rules/suppressors.js +99 -38
- package/dist/esm/rules/validators.d.ts +12 -0
- package/dist/esm/rules/validators.js +12 -0
- package/dist/esm/sdk.js +45 -3
- package/dist/esm/types.d.ts +10 -0
- package/dist/esm/types.js +10 -0
- package/package.json +4 -3
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local cues: what the document calls the value sitting next to it.
|
|
3
|
+
*
|
|
4
|
+
* A label touching the left edge of a span is the strongest statement a
|
|
5
|
+
* document makes about what that value *is*. "Phone: 0705237535" is a phone
|
|
6
|
+
* number even though those digits pass the Swedish personnummer checksum, and
|
|
7
|
+
* "Αρ. Ταυτότητας: 00892341" is an identity-card number even though nothing in
|
|
8
|
+
* the engine can checksum it.
|
|
9
|
+
*
|
|
10
|
+
* This table is the single source of that knowledge. Three callers read it, and
|
|
11
|
+
* none of them may move a span:
|
|
12
|
+
*
|
|
13
|
+
* 1. `localCueBonus` — ranks a candidate above its rivals on the *same span*
|
|
14
|
+
* when the cue names its type. The oldest use, and why the table exists.
|
|
15
|
+
* 2. `detectWithEvidence` — re-admits a candidate whose checksum failed when the
|
|
16
|
+
* cue names that same type. A declined identifier with its own label in front
|
|
17
|
+
* of it is still that identifier, mistyped or truncated.
|
|
18
|
+
* 3. `retypedBy` — relabels a winning generic candidate whose type the cue
|
|
19
|
+
* contradicts, when nothing of the cued type claimed the span.
|
|
20
|
+
*
|
|
21
|
+
* Consolidated here from two places that had drifted apart: `LOCAL_CUES` in
|
|
22
|
+
* `engine.ts` (typed, but missing most of Europe) and `ID_LABEL_BEFORE` in
|
|
23
|
+
* `suppressors.ts` (broader, but untyped — it could only *delete* a phone
|
|
24
|
+
* candidate, which left the value unmasked).
|
|
25
|
+
*
|
|
26
|
+
* Kept character-for-character in step with `euredact/rules/cues.py`. Two
|
|
27
|
+
* constructs are written the way they are so that both engines agree:
|
|
28
|
+
*
|
|
29
|
+
* * Boundaries are `(?<![A-Za-z0-9_])`, not `\b`. JavaScript's `\b` is
|
|
30
|
+
* ASCII-only, so `\bΑΦΜ` never matches — a Greek letter is not a word
|
|
31
|
+
* character there, and a space before it is not a transition. A boundary of
|
|
32
|
+
* some kind is required regardless: without one the short alternatives match
|
|
33
|
+
* the *tail* of an ordinary word. "NIS" matched "tālru**nis**", the Latvian
|
|
34
|
+
* for telephone, and suppressed 653 real phone numbers; "iva" matched
|
|
35
|
+
* "Pr**iva**t:" and read a personal e-mail address as a VAT number.
|
|
36
|
+
* * The qualifier class is spelled out rather than written `[^\W\d_]`, which
|
|
37
|
+
* would reject "εταιρειας" here for the same ASCII reason.
|
|
38
|
+
*
|
|
39
|
+
* Every label ends in `(?:\w*|\s+<one word>)`: the label either runs on into a
|
|
40
|
+
* longer word ("telefoon" → "telefoonnummer") **or** takes one qualifier word
|
|
41
|
+
* naming whose identifier it is ("ΑΦΜ εταιρειας:", "Tel Nr:") — never both.
|
|
42
|
+
* Allowing both let "nie" grow into the surname "Nieminen" and then swallow the
|
|
43
|
+
* real label in "Matti Nieminen\nDOB: 08.05.1994", reading a date of birth as a
|
|
44
|
+
* Spanish NIE: 52 false positives across the corpus.
|
|
45
|
+
*/
|
|
46
|
+
import { EntityType } from "../types.js";
|
|
47
|
+
/**
|
|
48
|
+
* How far back a cue may sit. Short on purpose: a cue is only evidence about
|
|
49
|
+
* the value it introduces, which is the lesson of the postal-code year defect,
|
|
50
|
+
* where a cue 150 characters away licensed every number in the window.
|
|
51
|
+
*/
|
|
52
|
+
export const CUE_WINDOW = 22;
|
|
53
|
+
/**
|
|
54
|
+
* (entity type, label pattern). Ordered: the first match wins, so the narrower
|
|
55
|
+
* label comes first where two could read the same text.
|
|
56
|
+
*/
|
|
57
|
+
export const CUES = [
|
|
58
|
+
// Phone. Ranking only — nothing is ever relabelled *to* PHONE: a phone cue in
|
|
59
|
+
// front of a span that no phone pattern matched says the document is laid out
|
|
60
|
+
// unusually, not that the value is a phone number.
|
|
61
|
+
[EntityType.PHONE,
|
|
62
|
+
/(?<![A-Za-z0-9_])(?:phone|tel|telephone|téléphone|telefoon|telefon|telefono|teléfono|tlf|mobil|mobile|gsm|handy|sími|puh|móvil|cell|tālrunis|τηλέφωνο|τηλ)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
63
|
+
// National identity number.
|
|
64
|
+
[EntityType.NATIONAL_ID,
|
|
65
|
+
/(?<![A-Za-z0-9_])(?:bsn|personnummer|rijksregisternummer|nationaal\s*nummer|numéro\s*national|national\s*(?:id|number)|identiteitsnummer|henkilötunnus|kennitala|cpr(?:-?nummer)?|nif|nie|dni|rr|nn|nir|insz|niss|nis|matricule|ausweisnummer|personalausweis|osobní\s*číslo|ЕГН|EGN)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
66
|
+
// Cyprus: "ID number" in both official languages, plus the Greek abbreviation
|
|
67
|
+
// ΑΔΤ (Αστυνομική Ταυτότητα). Neither has a phone reading.
|
|
68
|
+
[EntityType.NATIONAL_ID,
|
|
69
|
+
/(?<![A-Za-z0-9_])(?:αρ\.?\s*ταυτότητας|ταυτότητας|αδτ|kimlik\s*numaras[ıi])(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
70
|
+
// Social security.
|
|
71
|
+
[EntityType.SSN,
|
|
72
|
+
/(?<![A-Za-z0-9_])(?:ssn|social\s*security|sozialversicherungsnummer|svnr|sofi)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
73
|
+
// Tax number. Greece's ΑΦΜ is issued by the tax authority; the identity-card
|
|
74
|
+
// number is the ΑΔΤ, above. The engine used to return NATIONAL_ID for a value
|
|
75
|
+
// cued ΑΦΜ, which is wrong independently of the phone problem.
|
|
76
|
+
[EntityType.TAX_ID,
|
|
77
|
+
/(?<![A-Za-z0-9_])(?:αφμ|α\.φ\.μ\.|steuer-?id|steuernummer|st\.?-?nr|stnr|tin|finanzamt\s+ist|tax\s*(?:id|no|number)|daňové\s*číslo|numer\s*podatkowy)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
78
|
+
// Health insurance.
|
|
79
|
+
[EntityType.HEALTH_INSURANCE,
|
|
80
|
+
/(?<![A-Za-z0-9_])(?:versichertennummer|krankenversicherung|kvnr|kv-?nr|mutualité|ziekenfonds|aansluitingsnummer)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
81
|
+
// Company / trade register.
|
|
82
|
+
[EntityType.CHAMBER_OF_COMMERCE,
|
|
83
|
+
/(?<![A-Za-z0-9_])(?:siren|siret|kbo|bce|kvk|ondernemingsnummer|ondernemingen\s+onder\s+nummer|kruispuntbank|numéro\s*d'entreprise|enterprise\s*number|handelsregister|company\s*(?:no|number|reg)|organisationsnummer|orgnr|i[čc]o|identifikační\s*číslo|ЕИК|eik|bulstat|cvr(?:-?nummer)?|virksomhedsnummer)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
84
|
+
// VAT.
|
|
85
|
+
[EntityType.VAT,
|
|
86
|
+
/(?<![A-Za-z0-9_])(?:vat|btw|tva|ust|iva|moms|alv|mwst|dič|vsk)(?:[\w.\-]*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
87
|
+
// BIC / SWIFT.
|
|
88
|
+
[EntityType.BIC,
|
|
89
|
+
/(?<![A-Za-z0-9_])(?:bic|swift)[\w/]*\s*[:.\-]?\s*\(?\s*$/i],
|
|
90
|
+
// Postal code. A postal code loses every contested span on priority (it is
|
|
91
|
+
// the weakest evidence the engine has), so without a cue entry
|
|
92
|
+
// "Cod postal: 040171" was handed to a German phone pattern.
|
|
93
|
+
[EntityType.POSTAL_CODE,
|
|
94
|
+
/(?<![A-Za-z0-9_])(?:cod\s*po[sș]tal|postnummer|postnr|postleitzahl|plz|postcode|code\s*postal|codice\s*postale|kod\s*pocztowy|ps[čc]|τ\.?κ\.?|пощенски\s*код)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
95
|
+
// Internal / employee identifier. The canon calls these INTERNAL_ID and
|
|
96
|
+
// annotates them with an LLM, so the engine has no pattern for them — but it
|
|
97
|
+
// does have a phone pattern that was happily claiming them. The cue is the
|
|
98
|
+
// only thing that identifies one, which is why the engine emits this type
|
|
99
|
+
// from here and nowhere else.
|
|
100
|
+
[EntityType.INTERNAL_ID,
|
|
101
|
+
/(?<![A-Za-z0-9_])(?:medarbejdernummer|ansattnummer|anställningsnummer|personalnummer|personeelsnummer|matricule\s*salarié|employee\s*(?:no|number|id)|badge\s*(?:no|number)|osobní\s*(?:číslo\s*)?zaměstnance)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
|
|
102
|
+
];
|
|
103
|
+
/**
|
|
104
|
+
* The entity type a label immediately before `start` names, or null.
|
|
105
|
+
*
|
|
106
|
+
* Reads at most `CUE_WINDOW` characters, and every pattern is anchored to the
|
|
107
|
+
* end of that window, so the cue must *touch* the span. A label further away is
|
|
108
|
+
* about some other value.
|
|
109
|
+
*/
|
|
110
|
+
export function cuedType(text, start) {
|
|
111
|
+
const before = text.slice(Math.max(0, start - CUE_WINDOW), start);
|
|
112
|
+
for (const [entityType, pattern] of CUES) {
|
|
113
|
+
if (pattern.test(before))
|
|
114
|
+
return entityType;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Shapes so generic that a label in front is better evidence than the pattern
|
|
120
|
+
* that matched. A bare digit run is not a phone number because it *could* be
|
|
121
|
+
* dialled.
|
|
122
|
+
*/
|
|
123
|
+
const RETYPABLE = new Set([
|
|
124
|
+
EntityType.PHONE, EntityType.POSTAL_CODE, EntityType.LICENSE_PLATE,
|
|
125
|
+
]);
|
|
126
|
+
/**
|
|
127
|
+
* Types a label can assert on its own. Also the types eligible for the rescue
|
|
128
|
+
* in `engine.ts`, for the same reason read the other way — a label may vouch
|
|
129
|
+
* for one of these, so it may also vouch for a malformed one.
|
|
130
|
+
*
|
|
131
|
+
* PHONE is absent on purpose: "Tel:" in front of something no phone pattern
|
|
132
|
+
* matched means the document is laid out oddly, not that the value is a phone
|
|
133
|
+
* number. So are EMAIL, SECRET, DOB, VIN and the rest — those carry their own
|
|
134
|
+
* structure, and a nearby word is not entitled to overrule it.
|
|
135
|
+
*
|
|
136
|
+
* BIC's absence is what keeps the rescue honest. Its validator is a *registry
|
|
137
|
+
* lookup*, not a checksum, so a failure means "no such bank", which no label can
|
|
138
|
+
* talk you out of. Rescuing it read 34 unlisted codes behind a "BIC:" label as
|
|
139
|
+
* real ones. The same reasoning keeps CREDIT_CARD and BANK_ACCOUNT out: Luhn and
|
|
140
|
+
* mod-97 are strong enough that a failure really does mean "not one of these".
|
|
141
|
+
*/
|
|
142
|
+
export const CUE_TARGETS = new Set([
|
|
143
|
+
EntityType.NATIONAL_ID, EntityType.SSN, EntityType.TAX_ID,
|
|
144
|
+
EntityType.HEALTH_INSURANCE, EntityType.CHAMBER_OF_COMMERCE,
|
|
145
|
+
EntityType.VAT, EntityType.POSTAL_CODE, EntityType.PASSPORT,
|
|
146
|
+
EntityType.INTERNAL_ID,
|
|
147
|
+
]);
|
|
148
|
+
/**
|
|
149
|
+
* The type a cue overrules `entityType` with, or null to leave it alone.
|
|
150
|
+
*
|
|
151
|
+
* Both gates are load-bearing. Measured over 2,000 corpus documents, re-typing
|
|
152
|
+
* without them fired 52 times and got roughly 30 of those wrong — EMAIL read as
|
|
153
|
+
* VAT, a date of birth as a phone number, a VIN as VAT — against 20 real fixes.
|
|
154
|
+
* With them: 14 re-typings, every one of them PHONE to a structured identifier.
|
|
155
|
+
*/
|
|
156
|
+
export function retypedBy(text, start, entityType) {
|
|
157
|
+
if (!RETYPABLE.has(String(entityType)))
|
|
158
|
+
return null;
|
|
159
|
+
const cued = cuedType(text, start);
|
|
160
|
+
if (cued === null || cued === entityType || !CUE_TARGETS.has(String(cued)))
|
|
161
|
+
return null;
|
|
162
|
+
return cued;
|
|
163
|
+
}
|
package/dist/esm/rules/engine.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DetectionSource, EntityType } from "../types.js";
|
|
2
2
|
import { MultiPatternMatcher } from "./matchers.js";
|
|
3
|
-
import { shouldSuppress } from "./suppressors.js";
|
|
3
|
+
import { shouldSuppress, SuppressionScratch } from "./suppressors.js";
|
|
4
|
+
import { CUE_TARGETS, cuedType, retypedBy } from "./cues.js";
|
|
4
5
|
import { detectStructuralDob } from "./structural.js";
|
|
5
6
|
import { COUNTRY_CONFIGS } from "./countries/index.js";
|
|
6
7
|
import * as evidenceMod from "./evidence.js";
|
|
@@ -68,30 +69,13 @@ function warnUnknownCountry(code) {
|
|
|
68
69
|
//
|
|
69
70
|
// Measured: 263 phone numbers and 18 French SIREN were reported as national IDs,
|
|
70
71
|
// each with an explicit cue in front of it.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
/(?:siren|siret|kbo|bce|kvk|ondernemingsnummer|handelsregister|company\s*(?:no|number|reg)|organisationsnummer|orgnr)\w*\s*[:.\-]?\s*$/i],
|
|
76
|
-
[EntityType.VAT,
|
|
77
|
-
/(?:vat|btw|tva|ust|iva|moms|alv|mwst)[\w.\-]*\s*[:.\-]?\s*$/i],
|
|
78
|
-
[EntityType.BIC,
|
|
79
|
-
/(?:bic|swift)[\w/]*\s*[:.\-]?\s*\(?\s*$/i],
|
|
80
|
-
[EntityType.NATIONAL_ID,
|
|
81
|
-
/(?:bsn|personnummer|rijksregisternummer|national\s*id|identiteitsnummer|henkilötunnus|kennitala|cpr|nif|dni)\w*\s*[:.\-]?\s*$/i],
|
|
82
|
-
];
|
|
83
|
-
// How far back a cue may sit. Short on purpose: a cue is only evidence about the
|
|
84
|
-
// value it introduces, which is the lesson of the postal-code year defect, where
|
|
85
|
-
// a cue 150 characters away licensed every number in the window.
|
|
86
|
-
const CUE_WINDOW = 22;
|
|
72
|
+
//
|
|
73
|
+
// The table itself lives in `cues.ts`, because two other steps read it: the
|
|
74
|
+
// rescue of a declined identifier below, and the re-typing in deduplicate.
|
|
75
|
+
// `CUE_WINDOW` is re-exported from there.
|
|
87
76
|
/** 1 when a cue naming `entityType` sits immediately before the span. */
|
|
88
77
|
function localCueBonus(text, start, entityType) {
|
|
89
|
-
|
|
90
|
-
for (const [cuedType, pattern] of LOCAL_CUES) {
|
|
91
|
-
if (cuedType === entityType && pattern.test(before))
|
|
92
|
-
return 1;
|
|
93
|
-
}
|
|
94
|
-
return 0;
|
|
78
|
+
return cuedType(text, start) === entityType ? 1 : 0;
|
|
95
79
|
}
|
|
96
80
|
export class RuleEngine {
|
|
97
81
|
matcher = new MultiPatternMatcher();
|
|
@@ -198,7 +182,19 @@ export class RuleEngine {
|
|
|
198
182
|
// but failed the checksum is recorded with its country and entity type;
|
|
199
183
|
// what it is allowed to demote is decided below, once the document's
|
|
200
184
|
// countries are known.
|
|
185
|
+
//
|
|
186
|
+
// One kind of failure is rescued rather than recorded: a span the document
|
|
187
|
+
// itself labels as the very type whose checksum just failed.
|
|
188
|
+
// "Rijksregisternummer: 85.03.19-284.73" is a Belgian national number with a
|
|
189
|
+
// bad check digit — mistyped, OCR'd, or invented — and it is still a
|
|
190
|
+
// national number. Before this, the pattern declined, the generic phone rule
|
|
191
|
+
// was denied the span as a fragment, and the value was left in the clear: a
|
|
192
|
+
// redaction library printing an identifier it had recognised and rejected.
|
|
193
|
+
//
|
|
194
|
+
// Rescued candidates are still recorded as failed spans, so the demotion
|
|
195
|
+
// zones and the fragment rule below are unchanged.
|
|
201
196
|
const validated = [];
|
|
197
|
+
const rescued = [];
|
|
202
198
|
const failedSpans = [];
|
|
203
199
|
for (const m of rawMatches) {
|
|
204
200
|
if (this.matcher.validate(m)) {
|
|
@@ -207,6 +203,9 @@ export class RuleEngine {
|
|
|
207
203
|
else if (m.patternDef.validator !== null && !m.patternDef.requiresContext) {
|
|
208
204
|
failedSpans.push({ start: m.start, end: m.end, code: m.countryCode,
|
|
209
205
|
etype: String(m.patternDef.entityType) });
|
|
206
|
+
if (CUE_TARGETS.has(String(m.patternDef.entityType))
|
|
207
|
+
&& cuedType(text, m.start) === m.patternDef.entityType)
|
|
208
|
+
rescued.push(m);
|
|
210
209
|
}
|
|
211
210
|
}
|
|
212
211
|
// Evidence pass: work out which countries this document belongs to, from
|
|
@@ -273,6 +272,14 @@ export class RuleEngine {
|
|
|
273
272
|
const allFailed = failedSpans
|
|
274
273
|
.map(f => [f.start, f.end])
|
|
275
274
|
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
275
|
+
// Running maximum of the end offsets, so the fragment test below can ask
|
|
276
|
+
// "does any span starting at or before me reach past my end?" in O(1)
|
|
277
|
+
// instead of walking the prefix per candidate.
|
|
278
|
+
const failedStarts = allFailed.map(f => f[0]);
|
|
279
|
+
const failedPrefixMax = new Array(allFailed.length + 1).fill(0);
|
|
280
|
+
for (let i = 0; i < allFailed.length; i++) {
|
|
281
|
+
failedPrefixMax[i + 1] = Math.max(failedPrefixMax[i], allFailed[i][1]);
|
|
282
|
+
}
|
|
276
283
|
const mergedZones = new Map();
|
|
277
284
|
for (const [etype, spans] of zonesByType) {
|
|
278
285
|
spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
@@ -295,8 +302,11 @@ export class RuleEngine {
|
|
|
295
302
|
mergedZones.set(etype, { starts, ends });
|
|
296
303
|
}
|
|
297
304
|
const candidates = [];
|
|
305
|
+
// Whole-document facts a suppressor may need, derived at most once per call
|
|
306
|
+
// and discarded with it.
|
|
307
|
+
const scratch = new SuppressionScratch(text);
|
|
298
308
|
for (const { match, hasValidValidator } of validated) {
|
|
299
|
-
if (shouldSuppress(text, match))
|
|
309
|
+
if (shouldSuppress(text, match, scratch))
|
|
300
310
|
continue;
|
|
301
311
|
// A validator-less match inside a failed-validation span of its own type
|
|
302
312
|
// is demoted below every other candidate rather than deleted. Demotion
|
|
@@ -324,13 +334,31 @@ export class RuleEngine {
|
|
|
324
334
|
// default — the generic phone pattern kept claiming the tail of the
|
|
325
335
|
// rejected national number.
|
|
326
336
|
if (match.patternDef.validator === null && !demoted) {
|
|
337
|
+
// Upper bound of the spans starting at or before match.start.
|
|
338
|
+
let lo = 0, hi = failedStarts.length;
|
|
339
|
+
while (lo < hi) {
|
|
340
|
+
const mid = (lo + hi) >>> 1;
|
|
341
|
+
if (failedStarts[mid] <= match.start)
|
|
342
|
+
lo = mid + 1;
|
|
343
|
+
else
|
|
344
|
+
hi = mid;
|
|
345
|
+
}
|
|
327
346
|
let fragment = false;
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
347
|
+
const reach = failedPrefixMax[lo];
|
|
348
|
+
if (reach > match.end) {
|
|
349
|
+
// Something starting no later than us ends strictly later: we are a
|
|
350
|
+
// proper subset of it.
|
|
351
|
+
fragment = true;
|
|
352
|
+
}
|
|
353
|
+
else if (reach === match.end) {
|
|
354
|
+
// Same end offset — a proper subset only if some span with that end
|
|
355
|
+
// starts strictly earlier. Scanning backwards finds the enclosing
|
|
356
|
+
// span first, and the equal case is rare.
|
|
357
|
+
for (let i = lo - 1; i >= 0; i--) {
|
|
358
|
+
if (allFailed[i][1] === match.end && allFailed[i][0] < match.start) {
|
|
359
|
+
fragment = true;
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
334
362
|
}
|
|
335
363
|
}
|
|
336
364
|
if (fragment)
|
|
@@ -372,8 +400,28 @@ export class RuleEngine {
|
|
|
372
400
|
priority = 1;
|
|
373
401
|
blindPriority = 1;
|
|
374
402
|
}
|
|
375
|
-
|
|
403
|
+
let entityType = match.patternDef.entityType;
|
|
404
|
+
let country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
|
|
376
405
|
? match.countryCode : null;
|
|
406
|
+
let outOfScope = declared !== null && country !== null && !declared.has(match.countryCode);
|
|
407
|
+
let confidence = "high";
|
|
408
|
+
// The document labels this span as something else. Relabelling, not
|
|
409
|
+
// suppression: the span is found either way, so removing the claim would
|
|
410
|
+
// decide only whether the value is masked — and the answer was "no".
|
|
411
|
+
// "Rijksregisternummer: 85.03.19-284.73" produced no detection at all.
|
|
412
|
+
//
|
|
413
|
+
// The country came from the pattern that matched, and that pattern was
|
|
414
|
+
// just overruled: a Finnish phone rule saying "Finland" is worthless once
|
|
415
|
+
// the value is filed as a Cypriot identity number. Dropping it also clears
|
|
416
|
+
// outOfScope, which would otherwise claim the detection belongs to a
|
|
417
|
+
// country the caller did not declare while naming no country at all.
|
|
418
|
+
const retyped = retypedBy(text, match.start, entityType);
|
|
419
|
+
if (retyped !== null) {
|
|
420
|
+
entityType = retyped;
|
|
421
|
+
country = null;
|
|
422
|
+
outOfScope = false;
|
|
423
|
+
confidence = "medium";
|
|
424
|
+
}
|
|
377
425
|
candidates.push({
|
|
378
426
|
blindPriority,
|
|
379
427
|
cue: localCueBonus(text, match.start, match.patternDef.entityType),
|
|
@@ -381,6 +429,45 @@ export class RuleEngine {
|
|
|
381
429
|
length: match.end - match.start,
|
|
382
430
|
countryScore: countryScores.get(match.countryCode) ?? 0,
|
|
383
431
|
inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
|
|
432
|
+
tier: 0,
|
|
433
|
+
det: {
|
|
434
|
+
entityType,
|
|
435
|
+
start: match.start,
|
|
436
|
+
end: match.end,
|
|
437
|
+
text: match.text,
|
|
438
|
+
source: DetectionSource.RULES,
|
|
439
|
+
country,
|
|
440
|
+
confidence,
|
|
441
|
+
outOfScope,
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
// A declined identifier the document itself labels. Cue-backed by
|
|
446
|
+
// construction, so `cue` is 1 — that is what lets it take a span from a
|
|
447
|
+
// foreign pattern whose checksum happened to pass, which is how
|
|
448
|
+
// "ΑΦΜ: 147382965" was reported as a Czech national ID.
|
|
449
|
+
//
|
|
450
|
+
// `priority` is 0: below every regex-only claim, so it wins only what
|
|
451
|
+
// nothing better wants. `blindPriority` is 1 so that span selection — the
|
|
452
|
+
// country-blind half of the sort — treats it like any other regex-strength
|
|
453
|
+
// claim and invariant I1 is untouched.
|
|
454
|
+
//
|
|
455
|
+
// Emitted as "low", which is the honest description: the shape and the label
|
|
456
|
+
// agree, the check digit does not. A caller wanting only checksum-backed
|
|
457
|
+
// detections filters on it.
|
|
458
|
+
for (const match of rescued) {
|
|
459
|
+
if (shouldSuppress(text, match, scratch))
|
|
460
|
+
continue;
|
|
461
|
+
const country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
|
|
462
|
+
? match.countryCode : null;
|
|
463
|
+
candidates.push({
|
|
464
|
+
blindPriority: 1,
|
|
465
|
+
cue: 1,
|
|
466
|
+
priority: 0,
|
|
467
|
+
length: match.end - match.start,
|
|
468
|
+
countryScore: countryScores.get(match.countryCode) ?? 0,
|
|
469
|
+
inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
|
|
470
|
+
tier: 0,
|
|
384
471
|
det: {
|
|
385
472
|
entityType: match.patternDef.entityType,
|
|
386
473
|
start: match.start,
|
|
@@ -388,14 +475,14 @@ export class RuleEngine {
|
|
|
388
475
|
text: match.text,
|
|
389
476
|
source: DetectionSource.RULES,
|
|
390
477
|
country,
|
|
391
|
-
confidence: "
|
|
478
|
+
confidence: "low",
|
|
392
479
|
outOfScope: declared !== null && country !== null && !declared.has(match.countryCode),
|
|
393
480
|
},
|
|
394
481
|
});
|
|
395
482
|
}
|
|
396
483
|
for (const d of detectStructuralDob(text)) {
|
|
397
484
|
candidates.push({ blindPriority: 1, cue: 0, priority: 1, length: d.end - d.start,
|
|
398
|
-
countryScore: 0, inScope: 1, det: d });
|
|
485
|
+
countryScore: 0, inScope: 1, tier: 0, det: d });
|
|
399
486
|
}
|
|
400
487
|
const detections = this.deduplicate(candidates);
|
|
401
488
|
detections.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
@@ -429,7 +516,10 @@ export class RuleEngine {
|
|
|
429
516
|
if (seen === undefined || c.blindPriority > seen)
|
|
430
517
|
spanTier.set(key, c.blindPriority);
|
|
431
518
|
}
|
|
432
|
-
|
|
519
|
+
// Resolved once per candidate rather than inside the comparator, which ran
|
|
520
|
+
// it O(n log n) times and rebuilt the key string on every comparison.
|
|
521
|
+
for (const c of candidates)
|
|
522
|
+
c.tier = spanTier.get(`${c.det.start}:${c.det.end}`);
|
|
433
523
|
// Span length outranks *everything*, including validation.
|
|
434
524
|
//
|
|
435
525
|
// A shorter candidate winning re-cuts a longer one and leaves the
|
|
@@ -455,7 +545,7 @@ export class RuleEngine {
|
|
|
455
545
|
// country-blind up to and including `start`, and (length, start) already
|
|
456
546
|
// identifies a span uniquely.
|
|
457
547
|
const sorted = [...candidates].sort((a, b) => b.length - a.length || // longest first } country-blind
|
|
458
|
-
|
|
548
|
+
b.tier - a.tier || // best tier here } — decides
|
|
459
549
|
a.det.start - b.det.start || // leftmost } WHICH span
|
|
460
550
|
b.cue - a.cue || // local cue } country-aware
|
|
461
551
|
b.priority - a.priority || // country priority } — decides the
|
|
@@ -6,4 +6,17 @@ export interface RawMatch {
|
|
|
6
6
|
patternDef: PatternDef;
|
|
7
7
|
countryCode: string;
|
|
8
8
|
}
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Per-document scratch space for suppressors that would otherwise re-derive the
|
|
11
|
+
* same whole-document facts for every candidate.
|
|
12
|
+
*
|
|
13
|
+
* Built lazily and held only for the duration of one `detect()` call, so no
|
|
14
|
+
* document text outlives the call that supplied it.
|
|
15
|
+
*/
|
|
16
|
+
export declare class SuppressionScratch {
|
|
17
|
+
private readonly text;
|
|
18
|
+
private words;
|
|
19
|
+
constructor(text: string);
|
|
20
|
+
documentWords(): Set<string>;
|
|
21
|
+
}
|
|
22
|
+
export declare function shouldSuppress(text: string, match: RawMatch, scratch?: SuppressionScratch): boolean;
|