euredact 0.3.0 → 0.3.2
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 +213 -29
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/rules/context.js +50 -0
- package/dist/cjs/rules/countries/index.js +7 -1
- package/dist/cjs/rules/deDistricts.js +92 -0
- package/dist/cjs/rules/engine.js +231 -67
- package/dist/cjs/rules/evidence.js +201 -0
- package/dist/cjs/rules/matchers.js +10 -0
- package/dist/cjs/rules/suppressors.js +40 -1
- package/dist/cjs/sdk.js +23 -5
- package/dist/esm/index.d.ts +2 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/rules/context.d.ts +31 -0
- package/dist/esm/rules/context.js +46 -0
- package/dist/esm/rules/countries/index.js +7 -1
- package/dist/esm/rules/deDistricts.d.ts +16 -0
- package/dist/esm/rules/deDistricts.js +89 -0
- package/dist/esm/rules/engine.d.ts +28 -4
- package/dist/esm/rules/engine.js +198 -67
- package/dist/esm/rules/evidence.d.ts +63 -0
- package/dist/esm/rules/evidence.js +195 -0
- package/dist/esm/rules/matchers.d.ts +10 -0
- package/dist/esm/rules/matchers.js +10 -0
- package/dist/esm/rules/suppressors.js +40 -1
- package/dist/esm/sdk.d.ts +21 -0
- package/dist/esm/sdk.js +23 -5
- package/dist/esm/types.d.ts +53 -0
- package/package.json +5 -3
package/dist/cjs/rules/engine.js
CHANGED
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.RuleEngine = exports.COUNTRY_CODE_ALIASES = void 0;
|
|
4
37
|
exports.resolveCountryCode = resolveCountryCode;
|
|
@@ -7,6 +40,7 @@ const matchers_js_1 = require("./matchers.js");
|
|
|
7
40
|
const suppressors_js_1 = require("./suppressors.js");
|
|
8
41
|
const structural_js_1 = require("./structural.js");
|
|
9
42
|
const index_js_1 = require("./countries/index.js");
|
|
43
|
+
const evidenceMod = __importStar(require("./evidence.js"));
|
|
10
44
|
/**
|
|
11
45
|
* Heuristic to detect nested quantifiers that cause catastrophic backtracking.
|
|
12
46
|
* After stripping escaped chars and character classes, look for an unbounded
|
|
@@ -104,118 +138,248 @@ class RuleEngine {
|
|
|
104
138
|
if (newCountries)
|
|
105
139
|
this.matcher.compile();
|
|
106
140
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Generate candidate matches. Country-blind, by contract.
|
|
143
|
+
*
|
|
144
|
+
* This is the only caller of {@link MultiPatternMatcher.scan}, and it takes
|
|
145
|
+
* no country argument. Which country the caller declared may influence how a
|
|
146
|
+
* match is *scored*; it may never influence whether the match is *found*.
|
|
147
|
+
* Before this held, `countries: ["BE"]` made a valid Dutch BSN vanish
|
|
148
|
+
* entirely because the Dutch patterns were never run.
|
|
149
|
+
*/
|
|
150
|
+
generateCandidates(text) {
|
|
151
|
+
this.loadCountries(null);
|
|
152
|
+
return this.matcher.scan(text, null);
|
|
153
|
+
}
|
|
154
|
+
detect(text, countryCodes, countryHint) {
|
|
155
|
+
return this.detectWithEvidence(text, countryCodes, countryHint).detections;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* {@link RuleEngine.detect}, plus the country evidence it reasoned from.
|
|
159
|
+
*
|
|
160
|
+
* `countryCodes` is **scope**: it flags detections from elsewhere as
|
|
161
|
+
* `outOfScope` and, like `countryHint`, contributes a prior. `countryHint` is
|
|
162
|
+
* **only** a prior — it helps resolve ambiguity without marking anything out
|
|
163
|
+
* of scope. Neither gates what is looked for.
|
|
164
|
+
*/
|
|
165
|
+
detectWithEvidence(text, countryCodes, countryHint, priorEvidence) {
|
|
166
|
+
const rawMatches = this.generateCandidates(text);
|
|
167
|
+
// The declared countries are a scoring signal from here on: they rank
|
|
168
|
+
// candidates and flag out-of-scope detections. They do not gate.
|
|
169
|
+
let declared = null;
|
|
113
170
|
if (countryCodes !== null) {
|
|
114
|
-
|
|
171
|
+
declared = new Set();
|
|
115
172
|
for (const c of countryCodes) {
|
|
116
173
|
const code = resolveCountryCode(c);
|
|
117
|
-
if (code
|
|
118
|
-
|
|
174
|
+
if (code === null)
|
|
175
|
+
warnUnknownCountry(c);
|
|
176
|
+
else
|
|
177
|
+
declared.add(code);
|
|
119
178
|
}
|
|
120
|
-
|
|
121
|
-
|
|
179
|
+
declared.add("SHARED");
|
|
180
|
+
declared.add("CUSTOM");
|
|
181
|
+
}
|
|
182
|
+
// The prior fed to the evidence bus. Declared countries count too — a
|
|
183
|
+
// caller naming a country is asserting context, which is exactly what a
|
|
184
|
+
// prior is — but a hint never narrows scope, so it is kept out of
|
|
185
|
+
// `declared` and cannot flag anything out of scope.
|
|
186
|
+
const prior = new Set(declared ?? []);
|
|
187
|
+
for (const c of countryHint ?? []) {
|
|
188
|
+
const code = resolveCountryCode(c);
|
|
189
|
+
if (code === null)
|
|
190
|
+
warnUnknownCountry(c);
|
|
191
|
+
else
|
|
192
|
+
prior.add(code);
|
|
122
193
|
}
|
|
123
|
-
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
// positives and must be suppressed.
|
|
194
|
+
// Pass 1: checksum validation. A span that matched a checksummed pattern
|
|
195
|
+
// but failed the checksum is recorded with its country and entity type;
|
|
196
|
+
// what it is allowed to demote is decided below, once the document's
|
|
197
|
+
// countries are known.
|
|
128
198
|
const validated = [];
|
|
129
|
-
const
|
|
199
|
+
const failedSpans = [];
|
|
130
200
|
for (const m of rawMatches) {
|
|
131
|
-
|
|
132
|
-
if (isValid) {
|
|
201
|
+
if (this.matcher.validate(m)) {
|
|
133
202
|
validated.push({ match: m, hasValidValidator: m.patternDef.validator !== null });
|
|
134
203
|
}
|
|
135
204
|
else if (m.patternDef.validator !== null && !m.patternDef.requiresContext) {
|
|
136
|
-
|
|
205
|
+
failedSpans.push({ start: m.start, end: m.end, code: m.countryCode,
|
|
206
|
+
etype: String(m.patternDef.entityType) });
|
|
137
207
|
}
|
|
138
208
|
}
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
209
|
+
// Evidence pass: work out which countries this document belongs to, from
|
|
210
|
+
// the entities that carry their country in the string.
|
|
211
|
+
//
|
|
212
|
+
// Every surviving candidate is eligible, not only checksum-backed ones. An
|
|
213
|
+
// earlier version required a validator here, which silently dropped the
|
|
214
|
+
// email ccTLD — the second-strongest signal at 4.50 log-odds and present in
|
|
215
|
+
// 98,949 of 152,300 corpus documents — because EMAIL has nothing to
|
|
216
|
+
// checksum. Candidates that failed a validator never reach `validated`, so
|
|
217
|
+
// this admits the unvalidatable, not the invalid.
|
|
218
|
+
const evidenceDets = [];
|
|
219
|
+
for (const { match } of validated) {
|
|
220
|
+
if (!evidenceMod.EVIDENCE_TYPES.has(String(match.patternDef.entityType)))
|
|
221
|
+
continue;
|
|
222
|
+
evidenceDets.push({
|
|
223
|
+
entityType: match.patternDef.entityType,
|
|
224
|
+
start: match.start, end: match.end, text: match.text,
|
|
225
|
+
source: types_js_1.DetectionSource.RULES,
|
|
226
|
+
country: (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
|
|
227
|
+
? match.countryCode : null,
|
|
228
|
+
confidence: "high",
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
const countryEvidence = evidenceMod.collect(evidenceDets);
|
|
232
|
+
const countryScores = evidenceMod.accumulate(priorEvidence && priorEvidence.length ? [...countryEvidence, ...priorEvidence] : countryEvidence, prior);
|
|
233
|
+
const hasScores = countryScores.size > 0;
|
|
234
|
+
const corroborated = (code) => !hasScores || code === "SHARED" || code === "CUSTOM" || countryScores.has(code);
|
|
235
|
+
// Now that the document's countries are known, decide which failed
|
|
236
|
+
// validators are entitled to demote anything. A checksum failure only means
|
|
237
|
+
// "not a valid X"; it says nothing about the span unless X was plausible
|
|
238
|
+
// here in the first place. A Belgian national-ID pattern failing on
|
|
239
|
+
// 0612345678 is not evidence against that span being a Dutch phone number.
|
|
240
|
+
//
|
|
241
|
+
// Zones are also per entity type. A failed checksum is evidence against
|
|
242
|
+
// *that type*, not against the span: Sweden's own personnummer checksum
|
|
243
|
+
// failing on 0708787668 used to demote the Swedish *phone* candidate for
|
|
244
|
+
// the same digits, handing the span to a Danish CPR that happened to
|
|
245
|
+
// validate. Worth 878 mistyped phone numbers per 30,000 documents.
|
|
246
|
+
const zonesByType = new Map();
|
|
247
|
+
for (const f of failedSpans) {
|
|
248
|
+
if (!corroborated(f.code))
|
|
249
|
+
continue;
|
|
250
|
+
const list = zonesByType.get(f.etype);
|
|
251
|
+
if (list)
|
|
252
|
+
list.push([f.start, f.end]);
|
|
253
|
+
else
|
|
254
|
+
zonesByType.set(f.etype, [[f.start, f.end]]);
|
|
255
|
+
}
|
|
256
|
+
const mergedZones = new Map();
|
|
257
|
+
for (const [etype, spans] of zonesByType) {
|
|
258
|
+
spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
259
|
+
const starts = [];
|
|
260
|
+
const ends = [];
|
|
261
|
+
let [ms, me] = spans[0];
|
|
262
|
+
for (let i = 1; i < spans.length; i++) {
|
|
263
|
+
const [zs, ze] = spans[i];
|
|
264
|
+
if (zs <= me)
|
|
148
265
|
me = Math.max(me, ze);
|
|
149
|
-
}
|
|
150
266
|
else {
|
|
151
|
-
|
|
152
|
-
|
|
267
|
+
starts.push(ms);
|
|
268
|
+
ends.push(me);
|
|
153
269
|
ms = zs;
|
|
154
270
|
me = ze;
|
|
155
271
|
}
|
|
156
272
|
}
|
|
157
|
-
|
|
158
|
-
|
|
273
|
+
starts.push(ms);
|
|
274
|
+
ends.push(me);
|
|
275
|
+
mergedZones.set(etype, { starts, ends });
|
|
159
276
|
}
|
|
160
|
-
// Build candidates with priority: validated (3) > custom (2) > regex-only (1)
|
|
161
277
|
const candidates = [];
|
|
162
278
|
for (const { match, hasValidValidator } of validated) {
|
|
163
279
|
if ((0, suppressors_js_1.shouldSuppress)(text, match))
|
|
164
280
|
continue;
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
281
|
+
// A validator-less match inside a failed-validation span of its own type
|
|
282
|
+
// is demoted below every other candidate rather than deleted. Demotion
|
|
283
|
+
// cannot silence a detection, which is the property deletion lacked:
|
|
284
|
+
// measured across the corpus, deletion removed 454 detections of which
|
|
285
|
+
// 454 overlapped real labelled PII.
|
|
286
|
+
let demoted = false;
|
|
287
|
+
if (match.patternDef.validator === null) {
|
|
288
|
+
const zone = mergedZones.get(String(match.patternDef.entityType));
|
|
289
|
+
if (zone) {
|
|
290
|
+
let lo = 0, hi = zone.starts.length;
|
|
291
|
+
while (lo < hi) {
|
|
292
|
+
const mid = (lo + hi) >>> 1;
|
|
293
|
+
if (zone.starts[mid] <= match.start)
|
|
294
|
+
lo = mid + 1;
|
|
295
|
+
else
|
|
296
|
+
hi = mid;
|
|
297
|
+
}
|
|
298
|
+
const idx = lo - 1;
|
|
299
|
+
demoted = idx >= 0 && zone.ends[idx] >= match.end;
|
|
174
300
|
}
|
|
175
|
-
const idx = lo - 1;
|
|
176
|
-
if (idx >= 0 && zoneEnds[idx] >= match.end)
|
|
177
|
-
continue;
|
|
178
301
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
302
|
+
let priority;
|
|
303
|
+
if (demoted) {
|
|
304
|
+
priority = -1;
|
|
305
|
+
}
|
|
306
|
+
else if (hasValidValidator) {
|
|
307
|
+
// A passing checksum normally outranks everything. But a checksum only
|
|
308
|
+
// says the digits fit *some* national scheme, and weak ones fit by luck
|
|
309
|
+
// — a mod-11 scheme accepts a random number about one time in eleven.
|
|
310
|
+
// So a validated candidate from a country the document shows no trace
|
|
311
|
+
// of is treated as coincidence, not evidence.
|
|
312
|
+
//
|
|
313
|
+
// Concretely: the Dutch phone number 0612345678 also passes the Danish
|
|
314
|
+
// CPR checksum. Entities that carry their own country vouch for
|
|
315
|
+
// themselves — an IBAN emits evidence for its own country — so a
|
|
316
|
+
// foreign IBAN in a domestic invoice keeps its rank.
|
|
317
|
+
priority = corroborated(match.countryCode) ? 3 : 1;
|
|
318
|
+
}
|
|
319
|
+
else if (match.countryCode === "CUSTOM") {
|
|
320
|
+
priority = 2;
|
|
321
|
+
}
|
|
322
|
+
else if (match.patternDef.entityType === types_js_1.EntityType.POSTAL_CODE) {
|
|
323
|
+
// A bare digit run is the weakest evidence in the engine and must never
|
|
324
|
+
// re-cut a span a structured detector claims (PHONE, SSN, NATIONAL_ID,
|
|
325
|
+
// BANK_ACCOUNT, VAT...). Postal codes take unclaimed spans only,
|
|
326
|
+
// whatever the span lengths.
|
|
327
|
+
priority = 0;
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
priority = 1;
|
|
331
|
+
}
|
|
332
|
+
const country = (match.countryCode !== "SHARED" && match.countryCode !== "CUSTOM")
|
|
333
|
+
? match.countryCode : null;
|
|
192
334
|
candidates.push({
|
|
335
|
+
priority,
|
|
336
|
+
length: match.end - match.start,
|
|
337
|
+
countryScore: countryScores.get(match.countryCode) ?? 0,
|
|
338
|
+
inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
|
|
193
339
|
det: {
|
|
194
340
|
entityType: match.patternDef.entityType,
|
|
195
341
|
start: match.start,
|
|
196
342
|
end: match.end,
|
|
197
343
|
text: match.text,
|
|
198
344
|
source: types_js_1.DetectionSource.RULES,
|
|
199
|
-
country
|
|
345
|
+
country,
|
|
200
346
|
confidence: "high",
|
|
347
|
+
outOfScope: declared !== null && country !== null && !declared.has(match.countryCode),
|
|
201
348
|
},
|
|
202
|
-
score,
|
|
203
349
|
});
|
|
204
350
|
}
|
|
205
351
|
for (const d of (0, structural_js_1.detectStructuralDob)(text)) {
|
|
206
|
-
candidates.push({
|
|
352
|
+
candidates.push({ priority: 1, length: d.end - d.start, countryScore: 0, inScope: 1, det: d });
|
|
353
|
+
}
|
|
354
|
+
const detections = this.deduplicate(candidates);
|
|
355
|
+
detections.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
356
|
+
// Attach the confidence behind each country attribution, so a caller can
|
|
357
|
+
// tell "Danish, and the document is plainly Danish" from "Danish, on a
|
|
358
|
+
// checksum alone".
|
|
359
|
+
const ranking = evidenceMod.weightsToRanking(countryScores);
|
|
360
|
+
for (const d of detections) {
|
|
361
|
+
d.countryConfidence = (d.country !== null ? ranking.get(d.country) : undefined) ?? 0;
|
|
207
362
|
}
|
|
208
|
-
return
|
|
363
|
+
return { detections, evidence: countryEvidence, scores: countryScores };
|
|
209
364
|
}
|
|
210
365
|
/**
|
|
211
366
|
* Remove overlapping detections with priority-aware resolution.
|
|
212
|
-
* Priority: validated (3) > custom (2) > regex-only (1)
|
|
213
|
-
* Within
|
|
367
|
+
* Priority: validated (3) > custom (2) > regex-only (1) > postal (0) >
|
|
368
|
+
* demoted (-1). Within a tier, longer span wins, then country evidence.
|
|
214
369
|
*/
|
|
215
370
|
deduplicate(candidates) {
|
|
216
371
|
if (candidates.length === 0)
|
|
217
372
|
return [];
|
|
218
|
-
|
|
373
|
+
// Span length outranks scope: preferring the declared country over the
|
|
374
|
+
// longest match can truncate an entity. Measured: with countries ["BE"],
|
|
375
|
+
// the Belgian phone pattern claims 11 of the 14 characters of
|
|
376
|
+
// "06 12 34 56 78" and leaves three digits exposed. Scope only breaks ties
|
|
377
|
+
// between candidates of equal length, so it can change WHICH country is
|
|
378
|
+
// attributed, never WHAT is masked.
|
|
379
|
+
const sorted = [...candidates].sort((a, b) => b.priority - a.priority ||
|
|
380
|
+
b.length - a.length ||
|
|
381
|
+
b.countryScore - a.countryScore ||
|
|
382
|
+
b.inScope - a.inScope);
|
|
219
383
|
const result = [];
|
|
220
384
|
const occupied = new Set();
|
|
221
385
|
for (const { det } of sorted) {
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Country evidence: what a document says about which country it belongs to.
|
|
4
|
+
*
|
|
5
|
+
* Some entities carry their country in the string — an IBAN's country code, a
|
|
6
|
+
* `+CC` dialling prefix, a VAT prefix, an email ccTLD. Those are used to work
|
|
7
|
+
* out which country a document belongs to, which in turn decides which
|
|
8
|
+
* national scheme owns an ambiguous value elsewhere in the same document.
|
|
9
|
+
* 36.6% of national-ID values in the corpus validate under more than one
|
|
10
|
+
* country's checksum, so without this the engine cannot tell a Dutch BSN from
|
|
11
|
+
* a Polish PESEL.
|
|
12
|
+
*
|
|
13
|
+
* Evidence influences **scoring only**. It ranks candidates that already exist
|
|
14
|
+
* and annotates the result; it never decides what is looked for. A wrong
|
|
15
|
+
* inference must not be able to cause a miss.
|
|
16
|
+
*
|
|
17
|
+
* Weights are derived, not chosen — measured as P(document country = X |
|
|
18
|
+
* signal claims X) across the corpus. Kept identical to the Python SDK's
|
|
19
|
+
* `rules/evidence.py`; re-derive there and port, rather than tuning by feel.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.EVIDENCE_TYPES = exports.CC_TLDS = exports.DIALLING_CODES = exports.WEIGHTS = void 0;
|
|
23
|
+
exports.collect = collect;
|
|
24
|
+
exports.accumulate = accumulate;
|
|
25
|
+
exports.weightsToRanking = weightsToRanking;
|
|
26
|
+
const types_js_1 = require("../types.js");
|
|
27
|
+
// ── Derived weights ─────────────────────────────────────────────────────
|
|
28
|
+
//
|
|
29
|
+
// Measured on 152,300 documents:
|
|
30
|
+
//
|
|
31
|
+
// signal agree total P(doc=X|signal) raw log-odds
|
|
32
|
+
// e164Prefix 41,402 41,402 0.9999 9.21
|
|
33
|
+
// bicCountry 2,588 2,588 0.9999 9.21
|
|
34
|
+
// emailTld 97,865 98,949 0.9890 4.50
|
|
35
|
+
// vatPrefix 19,022 20,136 0.9447 2.84
|
|
36
|
+
// ibanPrefix 110,572 126,428 0.8746 1.94
|
|
37
|
+
//
|
|
38
|
+
// The IBAN prefix being the *weakest* signal is real and worth knowing: a
|
|
39
|
+
// Belgian IBAN in a Dutch invoice is ordinary, so the account's country is only
|
|
40
|
+
// weak evidence about the document's.
|
|
41
|
+
//
|
|
42
|
+
// The perfect scores are capped. They are perfect because the generator emits
|
|
43
|
+
// country-consistent values, not because a foreign mobile number never appears
|
|
44
|
+
// in a real document. Treating a synthetic 1.0 as certainty would make a single
|
|
45
|
+
// phone number unfalsifiable by any amount of contrary evidence.
|
|
46
|
+
const MAX_LOG_ODDS = 4.0;
|
|
47
|
+
exports.WEIGHTS = {
|
|
48
|
+
e164Prefix: Math.min(9.21, MAX_LOG_ODDS),
|
|
49
|
+
bicCountry: Math.min(9.21, MAX_LOG_ODDS),
|
|
50
|
+
emailTld: Math.min(4.5, MAX_LOG_ODDS),
|
|
51
|
+
vatPrefix: 2.84,
|
|
52
|
+
ibanPrefix: 1.94,
|
|
53
|
+
declared: 2.0, // the caller's `countries`, a prior rather than a measurement
|
|
54
|
+
};
|
|
55
|
+
// ── Signal tables ───────────────────────────────────────────────────────
|
|
56
|
+
/**
|
|
57
|
+
* E.164 calling code -> country, for the countries this engine supports. Codes
|
|
58
|
+
* are unique within this set; a shared code (+1, +7) would have to emit
|
|
59
|
+
* evidence for every candidate country rather than one.
|
|
60
|
+
*/
|
|
61
|
+
exports.DIALLING_CODES = {
|
|
62
|
+
"43": "AT", "32": "BE", "359": "BG", "41": "CH", "357": "CY", "420": "CZ",
|
|
63
|
+
"49": "DE", "45": "DK", "372": "EE", "30": "EL", "34": "ES", "358": "FI",
|
|
64
|
+
"33": "FR", "385": "HR", "36": "HU", "353": "IE", "354": "IS", "39": "IT",
|
|
65
|
+
"370": "LT", "352": "LU", "371": "LV", "356": "MT", "31": "NL", "47": "NO",
|
|
66
|
+
"48": "PL", "351": "PT", "40": "RO", "46": "SE", "386": "SI", "421": "SK",
|
|
67
|
+
"44": "UK",
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* ccTLD -> country. Deliberately excludes .eu/.com/.org/.net: those are not
|
|
71
|
+
* weak evidence, they are no evidence, and treating them as weak would let a
|
|
72
|
+
* .com address outvote a genuine national signal.
|
|
73
|
+
*/
|
|
74
|
+
exports.CC_TLDS = {
|
|
75
|
+
at: "AT", be: "BE", bg: "BG", ch: "CH", cy: "CY", cz: "CZ",
|
|
76
|
+
de: "DE", dk: "DK", ee: "EE", gr: "EL", es: "ES", fi: "FI",
|
|
77
|
+
fr: "FR", hr: "HR", hu: "HU", ie: "IE", is: "IS", it: "IT",
|
|
78
|
+
lt: "LT", lu: "LU", lv: "LV", mt: "MT", nl: "NL", no: "NO",
|
|
79
|
+
pl: "PL", pt: "PT", ro: "RO", se: "SE", si: "SI", sk: "SK",
|
|
80
|
+
uk: "UK",
|
|
81
|
+
};
|
|
82
|
+
/** VAT prefixes that are not the ISO code: Greece trades as EL, Northern
|
|
83
|
+
* Ireland as XI. Naive alpha-2 handling gets both wrong. */
|
|
84
|
+
const VAT_PREFIX_OVERRIDES = { EL: "EL", XI: "UK" };
|
|
85
|
+
const emitIban = (det) => {
|
|
86
|
+
const code = det.text.slice(0, 2).toUpperCase();
|
|
87
|
+
if (!/^[A-Z]{2}$/.test(code))
|
|
88
|
+
return [];
|
|
89
|
+
return [{ country: code, source: "ibanPrefix", logOdds: exports.WEIGHTS.ibanPrefix,
|
|
90
|
+
span: [det.start, det.end] }];
|
|
91
|
+
};
|
|
92
|
+
const emitE164 = (det) => {
|
|
93
|
+
if (!det.text.trimStart().startsWith("+"))
|
|
94
|
+
return [];
|
|
95
|
+
const digits = det.text.replace(/\D/g, "");
|
|
96
|
+
for (const len of [3, 2]) {
|
|
97
|
+
const country = exports.DIALLING_CODES[digits.slice(0, len)];
|
|
98
|
+
if (country) {
|
|
99
|
+
return [{ country, source: "e164Prefix", logOdds: exports.WEIGHTS.e164Prefix,
|
|
100
|
+
span: [det.start, det.end] }];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return [];
|
|
104
|
+
};
|
|
105
|
+
const emitVat = (det) => {
|
|
106
|
+
const prefix = det.text.slice(0, 2).toUpperCase();
|
|
107
|
+
if (!/^[A-Z]{2}$/.test(prefix))
|
|
108
|
+
return [];
|
|
109
|
+
const country = VAT_PREFIX_OVERRIDES[prefix] ?? prefix;
|
|
110
|
+
return [{ country, source: "vatPrefix", logOdds: exports.WEIGHTS.vatPrefix,
|
|
111
|
+
span: [det.start, det.end] }];
|
|
112
|
+
};
|
|
113
|
+
const emitBic = (det) => {
|
|
114
|
+
const clean = det.text.replace(/ /g, "").toUpperCase();
|
|
115
|
+
if (clean.length < 6)
|
|
116
|
+
return [];
|
|
117
|
+
return [{ country: clean.slice(4, 6), source: "bicCountry",
|
|
118
|
+
logOdds: exports.WEIGHTS.bicCountry, span: [det.start, det.end] }];
|
|
119
|
+
};
|
|
120
|
+
const emitTld = (det) => {
|
|
121
|
+
const parts = det.text.split(".");
|
|
122
|
+
const country = exports.CC_TLDS[parts[parts.length - 1].toLowerCase()];
|
|
123
|
+
if (!country)
|
|
124
|
+
return [];
|
|
125
|
+
return [{ country, source: "emailTld", logOdds: exports.WEIGHTS.emailTld,
|
|
126
|
+
span: [det.start, det.end] }];
|
|
127
|
+
};
|
|
128
|
+
/** Dispatch by entity type, mirroring the suppressor tables. */
|
|
129
|
+
const TYPE_EMITTERS = {
|
|
130
|
+
[types_js_1.EntityType.BANK_ACCOUNT]: [emitIban],
|
|
131
|
+
[types_js_1.EntityType.PHONE]: [emitE164],
|
|
132
|
+
[types_js_1.EntityType.VAT]: [emitVat],
|
|
133
|
+
[types_js_1.EntityType.BIC]: [emitBic],
|
|
134
|
+
[types_js_1.EntityType.EMAIL]: [emitTld],
|
|
135
|
+
};
|
|
136
|
+
/** Entity types that can carry country evidence. */
|
|
137
|
+
exports.EVIDENCE_TYPES = new Set(Object.keys(TYPE_EMITTERS));
|
|
138
|
+
/**
|
|
139
|
+
* Country evidence carried by *detections*, in document order.
|
|
140
|
+
*
|
|
141
|
+
* Deduplicated by (span, source): several countries' patterns can match the
|
|
142
|
+
* same IBAN, and counting that span once per pattern would let a single entity
|
|
143
|
+
* outvote the rest of the document.
|
|
144
|
+
*/
|
|
145
|
+
function collect(detections) {
|
|
146
|
+
const evidence = [];
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
for (const det of detections) {
|
|
149
|
+
for (const emit of TYPE_EMITTERS[det.entityType] ?? []) {
|
|
150
|
+
for (const ev of emit(det)) {
|
|
151
|
+
const key = `${ev.span[0]}:${ev.span[1]}:${ev.source}`;
|
|
152
|
+
if (!seen.has(key)) {
|
|
153
|
+
seen.add(key);
|
|
154
|
+
evidence.push(ev);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Document order, not pattern-registration order: this list is an audit
|
|
160
|
+
// trail, and a reader checking it against the text should not have to jump
|
|
161
|
+
// around. Also keeps the two SDKs byte-comparable.
|
|
162
|
+
evidence.sort((a, b) => a.span[0] - b.span[0] || a.span[1] - b.span[1]);
|
|
163
|
+
return evidence;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Sum evidence into a per-country log-odds score.
|
|
167
|
+
*
|
|
168
|
+
* Returns a weighted set, never a single winner: a Belgian supplier invoicing a
|
|
169
|
+
* German customer is not "70% Belgian", both countries are genuinely present,
|
|
170
|
+
* and an ambiguous value must be resolvable against either.
|
|
171
|
+
*/
|
|
172
|
+
function accumulate(evidence, declared) {
|
|
173
|
+
const scores = new Map();
|
|
174
|
+
for (const ev of evidence) {
|
|
175
|
+
scores.set(ev.country, (scores.get(ev.country) ?? 0) + ev.logOdds);
|
|
176
|
+
}
|
|
177
|
+
for (const country of declared ?? []) {
|
|
178
|
+
if (country !== "SHARED" && country !== "CUSTOM") {
|
|
179
|
+
scores.set(country, (scores.get(country) ?? 0) + exports.WEIGHTS.declared);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return scores;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Convert per-country log-odds into confidences in [0, 1].
|
|
186
|
+
*
|
|
187
|
+
* Each country is scored independently — a logistic on its own log-odds —
|
|
188
|
+
* because document countries are not mutually exclusive. A Belgian supplier
|
|
189
|
+
* invoicing a German customer is genuinely both; softmaxing across countries
|
|
190
|
+
* would report the German side of that invoice at 0.00 and make cross-border
|
|
191
|
+
* documents indistinguishable from misattributions.
|
|
192
|
+
*
|
|
193
|
+
* Confidences therefore do not sum to 1, by design.
|
|
194
|
+
*/
|
|
195
|
+
function weightsToRanking(scores) {
|
|
196
|
+
const out = new Map();
|
|
197
|
+
for (const [country, score] of scores) {
|
|
198
|
+
out.set(country, 1 / (1 + Math.exp(-score)));
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
@@ -22,6 +22,16 @@ class MultiPatternMatcher {
|
|
|
22
22
|
compile() {
|
|
23
23
|
this.compiled = true;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Scan text against patterns.
|
|
27
|
+
*
|
|
28
|
+
* `countryCodes` restricts which patterns run. **The engine never passes
|
|
29
|
+
* it.** Generation is country-blind by contract — see
|
|
30
|
+
* `RuleEngine.generateCandidates` and `src/__tests__/inference.ts` — because
|
|
31
|
+
* gating generation on the caller's declared country caused silent misses.
|
|
32
|
+
* The parameter is kept for a future opt-in fast mode, which would be the
|
|
33
|
+
* only caller permitted to trade recall for speed.
|
|
34
|
+
*/
|
|
25
35
|
scan(text, countryCodes = null) {
|
|
26
36
|
if (!this.compiled)
|
|
27
37
|
this.compile();
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.shouldSuppress = shouldSuppress;
|
|
4
4
|
const types_js_1 = require("../types.js");
|
|
5
5
|
const bicRegistry_js_1 = require("./bicRegistry.js");
|
|
6
|
+
const deDistricts_js_1 = require("./deDistricts.js");
|
|
6
7
|
const CONTEXT_CHARS = 150;
|
|
7
8
|
function getContext(text, start, end) {
|
|
8
9
|
const ctxStart = Math.max(0, start - CONTEXT_CHARS);
|
|
@@ -120,6 +121,12 @@ function suppressPhoneDateOverlap(_text, match) {
|
|
|
120
121
|
return false;
|
|
121
122
|
return DATE_PATTERN_FULL.test(match.text.trim());
|
|
122
123
|
}
|
|
124
|
+
// Standards and classification prefixes that are plate-shaped once a letter and
|
|
125
|
+
// digits follow: "ATC-N06", "ICD-O3". None is a German district code, but the
|
|
126
|
+
// guard is still gated on the absence of a plate cue, so a genuine plate that
|
|
127
|
+
// happens to collide is not lost.
|
|
128
|
+
const STANDARDS_PREFIX = new Set(["ICD", "ISO", "DIN", "IEC", "RFC", "DSM", "ATC", "MDR"]);
|
|
129
|
+
const PLATE_CUE_NEAR = /(?:Kennzeichen|Nummernschild|Kfz|Fahrzeug|amtliche[sn]?\s+Kennz|nummerplaat|plaque\s+d'immatriculation|license\s+plate|number\s+plate)/i;
|
|
123
130
|
const NOT_CITY_CODES = new Set([
|
|
124
131
|
"ID", "NR", "NO", "ST", "DR", "MR", "MS", "HR", "FR", "IM", "IN", "OR", "IF", "IS", "IT", "AT", "AD", "AG", "AV",
|
|
125
132
|
"BE", "DE", "EU", "NL", "LU", "WS", "SS", "IP",
|
|
@@ -138,6 +145,13 @@ function suppressPlateInCompound(text, match) {
|
|
|
138
145
|
}
|
|
139
146
|
const matched = match.text.trim();
|
|
140
147
|
const parts = matched.split(/[\s\-]+/);
|
|
148
|
+
// A standards or classification reference, not a plate — unless a plate cue
|
|
149
|
+
// nearby says otherwise.
|
|
150
|
+
if (parts.length > 0 && STANDARDS_PREFIX.has(parts[0].toUpperCase())) {
|
|
151
|
+
const [b, a] = getContext(text, match.start, match.end);
|
|
152
|
+
if (!PLATE_CUE_NEAR.test(b + a))
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
141
155
|
if (parts.length > 0 && NOT_CITY_CODES.has(parts[0])) {
|
|
142
156
|
const afterChar = match.end < text.length ? text[match.end] : "";
|
|
143
157
|
const beforeChar = match.start > 0 ? text[match.start - 1] : "";
|
|
@@ -160,6 +174,31 @@ function suppressPlateInCompound(text, match) {
|
|
|
160
174
|
}
|
|
161
175
|
return false;
|
|
162
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Reject a German plate whose district code is not a real one.
|
|
179
|
+
*
|
|
180
|
+
* The district-code set is closed, which makes it a whitelist rather than the
|
|
181
|
+
* open-ended blocklist of standards prefixes: "REF-A12", "SYS-B3", "KTO-A1"
|
|
182
|
+
* and every other document reference of that shape fail it without needing to
|
|
183
|
+
* be enumerated. Applied as a tier — an unknown code still emits when a plate
|
|
184
|
+
* cue is nearby, so a missing code costs recall only in the absence of any
|
|
185
|
+
* other evidence.
|
|
186
|
+
*/
|
|
187
|
+
function suppressDePlateUnknownDistrict(text, match) {
|
|
188
|
+
if (match.patternDef.entityType !== types_js_1.EntityType.LICENSE_PLATE)
|
|
189
|
+
return false;
|
|
190
|
+
if (match.countryCode !== "DE")
|
|
191
|
+
return false;
|
|
192
|
+
const parts = match.text.trim().split(/[\s\-]+/);
|
|
193
|
+
if (parts.length === 0 || !parts[0])
|
|
194
|
+
return false;
|
|
195
|
+
if (deDistricts_js_1.DE_DISTRICT_CODES.has(parts[0].toUpperCase()))
|
|
196
|
+
return false;
|
|
197
|
+
const [before, after] = getContext(text, match.start, match.end);
|
|
198
|
+
if (PLATE_CUE_NEAR.test(before + after))
|
|
199
|
+
return false;
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
163
202
|
function suppressNatidAsPassport(text, match) {
|
|
164
203
|
if (match.patternDef.entityType !== types_js_1.EntityType.NATIONAL_ID)
|
|
165
204
|
return false;
|
|
@@ -429,7 +468,7 @@ const TYPE_SUPPRESSORS = {
|
|
|
429
468
|
[types_js_1.EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalInsideIban, suppressPostalAsHouseNumber, suppressPostalInLongerIdentifier],
|
|
430
469
|
[types_js_1.EntityType.BIC]: [suppressBicWithoutEvidence],
|
|
431
470
|
[types_js_1.EntityType.BANK_ACCOUNT]: [suppressReference],
|
|
432
|
-
[types_js_1.EntityType.LICENSE_PLATE]: [suppressPlateInCompound],
|
|
471
|
+
[types_js_1.EntityType.LICENSE_PLATE]: [suppressPlateInCompound, suppressDePlateUnknownDistrict],
|
|
433
472
|
[types_js_1.EntityType.CHAMBER_OF_COMMERCE]: [suppressReference],
|
|
434
473
|
};
|
|
435
474
|
function shouldSuppress(text, match) {
|