euredact 0.5.0 → 0.6.0
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 +262 -13
- package/dist/cjs/cloud/client.js +7 -4
- package/dist/cjs/rules/countries/index.js +125 -42
- package/dist/cjs/rules/cues.js +21 -2
- package/dist/cjs/rules/suppressors.js +130 -5
- package/dist/cjs/rules/validators.js +53 -0
- package/dist/cjs/sdk.js +133 -38
- package/dist/esm/cloud/client.d.ts +7 -4
- package/dist/esm/cloud/client.js +7 -4
- package/dist/esm/rules/countries/index.js +125 -42
- package/dist/esm/rules/cues.d.ts +13 -1
- package/dist/esm/rules/cues.js +21 -2
- package/dist/esm/rules/suppressors.js +130 -5
- package/dist/esm/rules/validators.js +53 -0
- package/dist/esm/sdk.d.ts +12 -15
- package/dist/esm/sdk.js +133 -38
- package/package.json +1 -1
|
@@ -313,9 +313,85 @@ function suppressSequential(_text, match) {
|
|
|
313
313
|
const clean = match.text.replace(/[\s.\-]/g, "");
|
|
314
314
|
return SEQUENTIAL_PATTERNS.test(clean);
|
|
315
315
|
}
|
|
316
|
+
// ── The engine's own output ───────────────────────────────────────────────
|
|
317
|
+
//
|
|
318
|
+
// Redaction has to be idempotent: running it over a document it already
|
|
319
|
+
// produced must leave the placeholders it wrote alone. It did not.
|
|
320
|
+
// `[POSTAL_CODE]` is thirteen characters of mixed case with an underscore, so
|
|
321
|
+
// the entropy-based SECRET rule read it as a credential and a second pass
|
|
322
|
+
// replaced it with `[SECRET]` (rules-engine#33).
|
|
323
|
+
//
|
|
324
|
+
// All three emitted forms are guarded, because all three come back as input in
|
|
325
|
+
// a re-processing pipeline: `[TYPE]`, `TYPE_1` (referentialIntegrity) and
|
|
326
|
+
// `TYPE_K7Q2` (tokenize -- where a false detection breaks restore()).
|
|
327
|
+
//
|
|
328
|
+
// Only real entity-type names count. Guarding any bracketed upper-case token
|
|
329
|
+
// would be simpler and wrong: `[AKIAIOSFODNN7EXAMPLE]` is one, and also a live
|
|
330
|
+
// AWS key. A placeholder for a *custom* pattern is therefore not covered.
|
|
331
|
+
// The closing bracket is optional: another pattern's span need not agree with
|
|
332
|
+
// this one about where the marker ends. The assigned-secret rule stops before
|
|
333
|
+
// sentence punctuation (rules-engine#35), so it claims "[POSTAL_CODE" without
|
|
334
|
+
// the "]". The opening bracket is still required.
|
|
335
|
+
const PLACEHOLDER = /^(?:\[([A-Z][A-Z0-9_]*)\]?|([A-Z][A-Z0-9_]*?)_(?:\d+|[A-HJ-NP-Z2-9]{4}))$/;
|
|
336
|
+
const KNOWN_TYPE_NAMES = new Set(Object.values(types_js_1.EntityType));
|
|
337
|
+
function suppressRedactionPlaceholder(_text, match) {
|
|
338
|
+
const found = PLACEHOLDER.exec(match.text);
|
|
339
|
+
if (found === null)
|
|
340
|
+
return false;
|
|
341
|
+
const name = found[1] ?? found[2];
|
|
342
|
+
return KNOWN_TYPE_NAMES.has(name);
|
|
343
|
+
}
|
|
316
344
|
const RECENT_YEAR = /^(?:19[5-9]\d|20[0-3]\d)$/;
|
|
317
345
|
const POSTAL_CONTEXT_NEAR = /(?:postcode|postal|code\s*postal|PLZ|Postleitzahl|postnummer|postinumero|póstnúmer|zip|straat|straße|strasse|rue\s|via\s|calle\s|rua\s|ulica|utca|street|avenue|laan\s|weg\s|plein|adres|adresse|address|woonplaats|wonende|woonachtig|gevestigd|domicili|demeurant|résidant|residant|bosatt|bopæl|wohnhaft|ansässig|stad\b|ville\b|city\b|Stadt|città|ciudad|cidade|miasto|město|város)/i;
|
|
318
346
|
const DATE_KEYWORD_NEAR = /(?:født|fødselsdato|fødsel|Fødselsdato|född|födelsedatum|födelsedag|syntynyt|syntymäaika|fæddur|fæðingardagur|geboren|geboortedatum|Geburtsdatum|nascido|nacido|data di nascita|nato il|nata il|Tiltr[æa]delsesdato|Tiltredelsesdato|datum|date\b|Datum|jaar|year|année|Jahr|since|sinds|depuis|seit|\d{2}\.\d{2}\.|(?:januar|februar|marts|april|maj|juni|juli|august|september|oktober|november|december|januari|februari|mars|mei|juin|juillet|août))/i;
|
|
347
|
+
// A sentence boundary: a full stop, question or exclamation mark followed by
|
|
348
|
+
// space, or a line break. Written so it cannot fire inside "11.03.1970", where
|
|
349
|
+
// the periods are not followed by whitespace -- that date is one of the false
|
|
350
|
+
// positives this gate has to reject.
|
|
351
|
+
const SENTENCE_BREAK = /[.!?][ \t\n]|\n/;
|
|
352
|
+
const SENTENCE_BREAK_G = /[.!?][ \t\n]|\n/g;
|
|
353
|
+
// The value's own sentence, bounded either side.
|
|
354
|
+
function sentenceAround(text, start, end, reach = 160) {
|
|
355
|
+
let before = text.slice(Math.max(0, start - reach), start);
|
|
356
|
+
let after = text.slice(end, end + reach);
|
|
357
|
+
let lastEnd = -1;
|
|
358
|
+
SENTENCE_BREAK_G.lastIndex = 0;
|
|
359
|
+
for (let m = SENTENCE_BREAK_G.exec(before); m !== null; m = SENTENCE_BREAK_G.exec(before)) {
|
|
360
|
+
lastEnd = m.index + m[0].length;
|
|
361
|
+
}
|
|
362
|
+
if (lastEnd >= 0)
|
|
363
|
+
before = before.slice(lastEnd);
|
|
364
|
+
const found = SENTENCE_BREAK.exec(after);
|
|
365
|
+
if (found !== null)
|
|
366
|
+
after = after.slice(0, found.index);
|
|
367
|
+
return before + after;
|
|
368
|
+
}
|
|
369
|
+
// A postal label whose only distance from the value is punctuation:
|
|
370
|
+
// "PLZ: 2011", "code postal : 2011", "Woonplaats: 1950".
|
|
371
|
+
const POSTAL_LABEL_TOUCHING = new RegExp(`(?:${POSTAL_CONTEXT_NEAR.source})[\\s:.\\-]{0,4}$`, "i");
|
|
372
|
+
// A capitalised place name after the code: "2000 Antwerpen". A following label
|
|
373
|
+
// ("1970 Fødselsnummer:") is excluded by the colon.
|
|
374
|
+
const CITY_AFTER = /^[ \t](?!\w+\s*:)[A-ZÀ-ÞŁŠŽ][\w\-']{2,}/u;
|
|
375
|
+
// Every position is local to the value or inside its own sentence. Proximity to
|
|
376
|
+
// an address *word* anywhere in a 300-character window is what the old rescue
|
|
377
|
+
// asked, and that is a neighbourhood rather than a position (rules-engine#32).
|
|
378
|
+
function yearSitsInAddressStructure(text, start, end) {
|
|
379
|
+
const before = text.slice(Math.max(0, start - 28), start);
|
|
380
|
+
// "Amsterdam, 2026" -- the comma of an address line.
|
|
381
|
+
if (/,[ \t]*$/.test(before))
|
|
382
|
+
return true;
|
|
383
|
+
// "PLZ: 2011" -- a postal label with nothing but punctuation between.
|
|
384
|
+
if (POSTAL_LABEL_TOUCHING.test(before))
|
|
385
|
+
return true;
|
|
386
|
+
// A capitalised place name immediately after.
|
|
387
|
+
if (CITY_AFTER.test(text.slice(end, end + 24)))
|
|
388
|
+
return true;
|
|
389
|
+
// An address word in the value's own sentence. "Te huur: Lange Nieuwstraat
|
|
390
|
+
// 12, rustige ligging in 2018, vlakbij openbaar vervoer." is an Antwerp
|
|
391
|
+
// postal code in prose; the false positives put the year and the address in
|
|
392
|
+
// different sentences.
|
|
393
|
+
return POSTAL_CONTEXT_NEAR.test(sentenceAround(text, start, end));
|
|
394
|
+
}
|
|
319
395
|
function suppressYearAsPostal(text, match) {
|
|
320
396
|
if (match.patternDef.entityType !== types_js_1.EntityType.POSTAL_CODE)
|
|
321
397
|
return false;
|
|
@@ -334,8 +410,10 @@ function suppressYearAsPostal(text, match) {
|
|
|
334
410
|
return true;
|
|
335
411
|
const [before, after] = getContext(text, match.start, match.end);
|
|
336
412
|
const context = before + after;
|
|
337
|
-
// Keep as postal code
|
|
338
|
-
|
|
413
|
+
// Keep as postal code only when the value sits in address *structure* --
|
|
414
|
+
// see yearSitsInAddressStructure for why a 300-character window of address
|
|
415
|
+
// words was the defect rather than the keyword list.
|
|
416
|
+
if (yearSitsInAddressStructure(text, match.start, match.end))
|
|
339
417
|
return false;
|
|
340
418
|
// Keep if preceded by comma+space (address pattern: "Amsterdam, 2026")
|
|
341
419
|
const immediateBefore = text.slice(Math.max(0, match.start - 3), match.start);
|
|
@@ -510,6 +588,35 @@ function suppressPostalInsideIban(text, match) {
|
|
|
510
588
|
}
|
|
511
589
|
return false;
|
|
512
590
|
}
|
|
591
|
+
// An IBAN-shaped run: two letters, two check digits, then grouped
|
|
592
|
+
// alphanumerics. Deliberately not checksum-gated -- the point is to describe the
|
|
593
|
+
// run a human wrote as one account number, including the ones that fail their
|
|
594
|
+
// checksum, because those are exactly the ones no structured detector claims.
|
|
595
|
+
const ACCOUNT_RUN = /\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]{2,4}){2,8}\b/g;
|
|
596
|
+
// "Compte: FR76 3000 4008 0300 0109 5374" fails the IBAN checksum, so no
|
|
597
|
+
// BANK_ACCOUNT candidate survives and the Danish eight-digit phone pattern took
|
|
598
|
+
// "3000 4008" out of the middle of it, leaving the rest in the clear
|
|
599
|
+
// (rules-engine#30). POSTAL_CODE has carried its own version of this guard
|
|
600
|
+
// since 0.3.3, in suppressPostalInsideIban.
|
|
601
|
+
function suppressPhoneInsideAccountRun(text, match) {
|
|
602
|
+
if (match.patternDef.entityType !== types_js_1.EntityType.PHONE)
|
|
603
|
+
return false;
|
|
604
|
+
const lineStart = text.lastIndexOf("\n", match.start) + 1;
|
|
605
|
+
let lineEnd = text.indexOf("\n", match.end);
|
|
606
|
+
if (lineEnd < 0)
|
|
607
|
+
lineEnd = text.length;
|
|
608
|
+
const line = text.slice(lineStart, lineEnd);
|
|
609
|
+
ACCOUNT_RUN.lastIndex = 0;
|
|
610
|
+
for (let m = ACCOUNT_RUN.exec(line); m !== null; m = ACCOUNT_RUN.exec(line)) {
|
|
611
|
+
const runStart = lineStart + m.index;
|
|
612
|
+
const runEnd = runStart + m[0].length;
|
|
613
|
+
if (runStart <= match.start && match.end <= runEnd
|
|
614
|
+
&& runEnd - runStart > match.end - match.start) {
|
|
615
|
+
return true;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
513
620
|
function suppressPostalAsHouseNumber(text, match) {
|
|
514
621
|
if (match.patternDef.entityType !== types_js_1.EntityType.POSTAL_CODE)
|
|
515
622
|
return false;
|
|
@@ -738,12 +845,19 @@ function suppressBicWithoutEvidence(text, match, scratch) {
|
|
|
738
845
|
// Repeated per alternative, the engine retried the same unbounded prefix five
|
|
739
846
|
// times per starting position — the most expensive single regex in the engine
|
|
740
847
|
// at 197 ms per 1 MB document. Same language, 8x faster on the worst case.
|
|
848
|
+
// An identifier cue that is really a postal label -- see the use below.
|
|
849
|
+
const POSTAL_LABEL_CUE = /^post/i;
|
|
741
850
|
const ID_CUE_BEFORE = /(?:[\w\-]*(?:Nr|N[°ºo]|Nummer|Numero|Numéro)|No|number|num|Kennzahl|Aktenzeichen|Az|e-?card|Polizze|Police|Policen)\.?\s*:?\s*$/i;
|
|
742
851
|
// An international dialling prefix earlier on the same line, with nothing but
|
|
743
852
|
// number punctuation in between: these digits belong to the phone detector.
|
|
744
853
|
const DIALLING_PREFIX_BEFORE = /\+\d{1,3}[\d\s\-().]*$/;
|
|
745
854
|
// A country prefix on a postal code — "A-1010 Wien", "B-2000", "L-1234".
|
|
746
|
-
|
|
855
|
+
// The boundary excludes a hyphen, which is what makes this a *prefix* rather
|
|
856
|
+
// than a segment. It did not, so any `XX-NNNNN` tail of a hyphenated reference
|
|
857
|
+
// read as a country-prefixed address: in "PV-2026-LU-09143" the `LU` is
|
|
858
|
+
// preceded by `-` and the German five-digit postal pattern kept `09143`
|
|
859
|
+
// (rules-engine#31).
|
|
860
|
+
const COUNTRY_PREFIXED = /(?:^|[^A-Za-z0-9\-])[A-Z]{1,2}$/;
|
|
747
861
|
/**
|
|
748
862
|
* Suppress bare digit runs that belong to a longer number, not an address.
|
|
749
863
|
*
|
|
@@ -787,7 +901,16 @@ function suppressPostalInLongerIdentifier(text, match) {
|
|
|
787
901
|
if (/^[ \t]+\d/.test(text.slice(match.end, match.end + 4)))
|
|
788
902
|
return true;
|
|
789
903
|
// An identifier label introduces the digits: "DiNr. 4471", "Policen-Nr."
|
|
790
|
-
|
|
904
|
+
// Unless the label is a *postal* one. ID_CUE_BEFORE matches any word ending
|
|
905
|
+
// in "Nummer", "Nr", "Numero" or "Numéro" -- the wildcard is `[\w\-]*` -- so
|
|
906
|
+
// it matches `postnummer`, `postnr` and `postinumero`, the canonical postal
|
|
907
|
+
// labels of Norway, Denmark and Finland. Those countries write a bare
|
|
908
|
+
// four- or five-digit code, so the isdigit() guard above lets this run and
|
|
909
|
+
// the code was suppressed by its own label: `postnummer: 5020 Bergen`
|
|
910
|
+
// produced nothing at all (rules-engine#41).
|
|
911
|
+
const before = text.slice(Math.max(0, match.start - 40), match.start);
|
|
912
|
+
const cue = ID_CUE_BEFORE.exec(before);
|
|
913
|
+
if (cue !== null && !POSTAL_LABEL_CUE.test(cue[0].trimStart()))
|
|
791
914
|
return true;
|
|
792
915
|
// Digits after an international dialling prefix belong to PHONE
|
|
793
916
|
const [lineStart] = enclosingLine(text, match.start, match.end);
|
|
@@ -839,7 +962,7 @@ function suppressRequiresContext(text, match) {
|
|
|
839
962
|
return !match.patternDef.contextKeywords.some(kw => context.includes(kw.toLowerCase()));
|
|
840
963
|
}
|
|
841
964
|
const TYPE_SUPPRESSORS = {
|
|
842
|
-
[types_js_1.EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneServiceNumber, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
|
|
965
|
+
[types_js_1.EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneServiceNumber, suppressPhoneInsideAccountRun, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
|
|
843
966
|
[types_js_1.EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
|
|
844
967
|
[types_js_1.EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
|
|
845
968
|
[types_js_1.EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressTaxidAsIpAddress],
|
|
@@ -853,6 +976,8 @@ const TYPE_SUPPRESSORS = {
|
|
|
853
976
|
function shouldSuppress(text, match, scratch) {
|
|
854
977
|
if (suppressSequential(text, match))
|
|
855
978
|
return true;
|
|
979
|
+
if (suppressRedactionPlaceholder(text, match))
|
|
980
|
+
return true;
|
|
856
981
|
const typeSups = TYPE_SUPPRESSORS[match.patternDef.entityType];
|
|
857
982
|
if (typeSups) {
|
|
858
983
|
for (const s of typeSups) {
|
|
@@ -513,10 +513,24 @@ function validatePortugueseNif(candidate) {
|
|
|
513
513
|
const check = remainder < 2 ? 0 : 11 - remainder;
|
|
514
514
|
return parseInt(c[8]) === check;
|
|
515
515
|
}
|
|
516
|
+
// PESEL carries the century in the month field: +0 is the 1900s, +20 the 2000s,
|
|
517
|
+
// +40 the 2100s, +60 the 2200s, +80 the 1800s. Without a date check this
|
|
518
|
+
// accepted 80666603210 -- day 66 -- as NATIONAL_ID at high confidence
|
|
519
|
+
// (rules-engine#43).
|
|
516
520
|
function validatePolishPesel(candidate) {
|
|
517
521
|
const c = candidate.replace(/[\s\-]/g, "");
|
|
518
522
|
if (c.length !== 11 || !/^\d{11}$/.test(c))
|
|
519
523
|
return false;
|
|
524
|
+
const rawMonth = parseInt(c.slice(2, 4), 10);
|
|
525
|
+
let month = null;
|
|
526
|
+
for (const off of [0, 20, 40, 60, 80]) {
|
|
527
|
+
if (rawMonth - off >= 1 && rawMonth - off <= 12) {
|
|
528
|
+
month = rawMonth - off;
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (month === null || !isRealDay(month, parseInt(c.slice(4, 6), 10)))
|
|
533
|
+
return false;
|
|
520
534
|
const d = c.split("").map(Number);
|
|
521
535
|
const weights = [1, 3, 7, 9, 1, 3, 7, 9, 1, 3];
|
|
522
536
|
let total = 0;
|
|
@@ -535,18 +549,53 @@ function validatePolishNip(candidate) {
|
|
|
535
549
|
total += d[i] * weights[i];
|
|
536
550
|
return total % 11 === d[9];
|
|
537
551
|
}
|
|
552
|
+
// Days per month for the rodné číslo date check. February is 29 because the
|
|
553
|
+
// century is not recoverable from a two-digit year, so a leap year cannot be
|
|
554
|
+
// ruled out -- being permissive by one day is the right direction here.
|
|
555
|
+
const RC_DAYS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
556
|
+
// The calendar month a rodné číslo month field encodes, or null. Four ranges
|
|
557
|
+
// are legal: 1-12 is a man, +50 a woman, and since 2004 +20 marks a birth on a
|
|
558
|
+
// day whose sequence numbers were exhausted (so +70 is a woman on such a day).
|
|
559
|
+
// Whether `day` exists in `month`, February permissive at 29 because a
|
|
560
|
+
// two-digit year does not reveal the century.
|
|
561
|
+
function isRealDay(month, day) {
|
|
562
|
+
return month >= 1 && month <= 12 && day >= 1 && day <= RC_DAYS[month - 1];
|
|
563
|
+
}
|
|
564
|
+
function czechMonth(raw) {
|
|
565
|
+
for (const offset of [0, 20, 50, 70]) {
|
|
566
|
+
if (raw - offset >= 1 && raw - offset <= 12)
|
|
567
|
+
return raw - offset;
|
|
568
|
+
}
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
// The date is checked, not only the checksum. Without it the validator accepted
|
|
572
|
+
// an impossible month, and a Czech mobile number is the same shape -- nine
|
|
573
|
+
// digits opening 6 or 7 -- so any mobile that happened to pass mod 11 was typed
|
|
574
|
+
// NATIONAL_ID at high confidence: 606666032 (MM=66), 778836400 (MM=88),
|
|
575
|
+
// 728990603 (MM=89). 284 per corpus pass, the largest single false-positive
|
|
576
|
+
// bucket in the evaluation (rules-engine#37).
|
|
538
577
|
function validateCzechBirthNumber(candidate) {
|
|
539
578
|
const c = candidate.replace(/[/\s]/g, "");
|
|
540
579
|
if (!/^\d+$/.test(c) || (c.length !== 9 && c.length !== 10))
|
|
541
580
|
return false;
|
|
581
|
+
const month = czechMonth(parseInt(c.slice(2, 4), 10));
|
|
582
|
+
if (month === null)
|
|
583
|
+
return false;
|
|
584
|
+
const day = parseInt(c.slice(4, 6), 10);
|
|
585
|
+
if (day < 1 || day > RC_DAYS[month - 1])
|
|
586
|
+
return false;
|
|
542
587
|
if (c.length === 10)
|
|
543
588
|
return parseInt(c) % 11 === 0;
|
|
544
589
|
return true;
|
|
545
590
|
}
|
|
591
|
+
// S YY MM DD JJ NNN C -- the leading S carries the century, so the month needs
|
|
592
|
+
// no offset convention here (rules-engine#43).
|
|
546
593
|
function validateRomanianCnp(candidate) {
|
|
547
594
|
const c = candidate.replace(/[\s\-]/g, "");
|
|
548
595
|
if (c.length !== 13 || !/^\d{13}$/.test(c) || !"12345678".includes(c[0]))
|
|
549
596
|
return false;
|
|
597
|
+
if (!isRealDay(parseInt(c.slice(3, 5), 10), parseInt(c.slice(5, 7), 10)))
|
|
598
|
+
return false;
|
|
550
599
|
const key = "279146358279";
|
|
551
600
|
let total = 0;
|
|
552
601
|
for (let i = 0; i < 12; i++)
|
|
@@ -593,10 +642,14 @@ function validateCroatianOib(candidate) {
|
|
|
593
642
|
const check = (11 - product) % 10;
|
|
594
643
|
return check === parseInt(c[10]);
|
|
595
644
|
}
|
|
645
|
+
// DD MM YYY RR BBB K -- note EMSO opens with the *day*, where the Czech, Polish
|
|
646
|
+
// and Romanian forms open with the year or a century digit (rules-engine#43).
|
|
596
647
|
function validateSlovenianEmso(candidate) {
|
|
597
648
|
const c = candidate.replace(/[\s\-]/g, "");
|
|
598
649
|
if (c.length !== 13 || !/^\d{13}$/.test(c))
|
|
599
650
|
return false;
|
|
651
|
+
if (!isRealDay(parseInt(c.slice(2, 4), 10), parseInt(c.slice(0, 2), 10)))
|
|
652
|
+
return false;
|
|
600
653
|
const d = c.split("").map(Number);
|
|
601
654
|
const weights = [7, 6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
|
|
602
655
|
let total = 0;
|
package/dist/cjs/sdk.js
CHANGED
|
@@ -86,6 +86,96 @@ function applyReplacements(text, detections, labelFor) {
|
|
|
86
86
|
parts.push(text.slice(pos));
|
|
87
87
|
return parts.join("");
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Bracket-mask `text` and record where each label landed.
|
|
91
|
+
*
|
|
92
|
+
* Returns what is sent to the cloud tier, plus one `WireLabel` per label.
|
|
93
|
+
* Everything between labels is copied unchanged, so the labels are all it
|
|
94
|
+
* takes to carry an offset from the masked text back to the original.
|
|
95
|
+
*
|
|
96
|
+
* Always `[TYPE]`, whatever label scheme the caller asked for. That is the
|
|
97
|
+
* syntax the model was trained on, where a placeholder means "already handled,
|
|
98
|
+
* do not list it". A token or a numbered label would be read as ordinary text,
|
|
99
|
+
* so the format on the wire is a model contract and not an output option.
|
|
100
|
+
*/
|
|
101
|
+
function maskForCloud(text, detections) {
|
|
102
|
+
const parts = [];
|
|
103
|
+
const labels = [];
|
|
104
|
+
let pos = 0;
|
|
105
|
+
let out = 0;
|
|
106
|
+
for (const det of detections) {
|
|
107
|
+
const start = Math.max(det.start, pos);
|
|
108
|
+
if (det.end <= start)
|
|
109
|
+
continue;
|
|
110
|
+
const label = `[${det.entityType}]`;
|
|
111
|
+
out += start - pos;
|
|
112
|
+
parts.push(text.slice(pos, start), label);
|
|
113
|
+
labels.push([out, out + label.length, start, det.end]);
|
|
114
|
+
out += label.length;
|
|
115
|
+
pos = det.end;
|
|
116
|
+
}
|
|
117
|
+
parts.push(text.slice(pos));
|
|
118
|
+
return [parts.join(""), labels];
|
|
119
|
+
}
|
|
120
|
+
/** Index of the last label starting at or before `offset` — strictly before
|
|
121
|
+
* when `strict` — or -1. */
|
|
122
|
+
function labelBefore(labels, offset, strict) {
|
|
123
|
+
let lo = 0;
|
|
124
|
+
let hi = labels.length;
|
|
125
|
+
while (lo < hi) {
|
|
126
|
+
const mid = (lo + hi) >>> 1;
|
|
127
|
+
if (strict ? labels[mid][0] < offset : labels[mid][0] <= offset)
|
|
128
|
+
lo = mid + 1;
|
|
129
|
+
else
|
|
130
|
+
hi = mid;
|
|
131
|
+
}
|
|
132
|
+
return lo - 1;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Carry the service's spans from the masked text onto the original.
|
|
136
|
+
*
|
|
137
|
+
* The service only ever saw `masked`, so its offsets index that. An offset in
|
|
138
|
+
* copied text shifts by whatever the labels before it added or removed. An
|
|
139
|
+
* offset inside a label has no counterpart in the original, so it snaps
|
|
140
|
+
* outward to the edge of the value the label replaced: an address the model
|
|
141
|
+
* reports around `[POSTAL_CODE]` covers the postal code, never half of it.
|
|
142
|
+
* Over-masking is the safe direction.
|
|
143
|
+
*
|
|
144
|
+
* A span lying wholly inside one label describes the label, not the document.
|
|
145
|
+
* It is dropped; the local detection behind the label stands.
|
|
146
|
+
*
|
|
147
|
+
* Throws `Unplaceable` when a span does not match the text that was sent. Such
|
|
148
|
+
* a span cannot be placed, and masking where it points would cover the wrong
|
|
149
|
+
* characters and leave the right ones in the clear.
|
|
150
|
+
*/
|
|
151
|
+
function ontoOriginal(spans, masked, text, labels, Unplaceable) {
|
|
152
|
+
const placed = [];
|
|
153
|
+
for (const span of spans) {
|
|
154
|
+
if (!(0 <= span.start && span.start <= span.end && span.end <= masked.length) ||
|
|
155
|
+
masked.slice(span.start, span.end) !== span.text) {
|
|
156
|
+
throw new Unplaceable("span offsets do not match the text that was sent; cannot place the " +
|
|
157
|
+
"service's detections in the document");
|
|
158
|
+
}
|
|
159
|
+
if (span.start === span.end)
|
|
160
|
+
continue;
|
|
161
|
+
let start = span.start;
|
|
162
|
+
let at = labelBefore(labels, span.start, false);
|
|
163
|
+
if (at >= 0) {
|
|
164
|
+
const [, maskedEnd, origStart, origEnd] = labels[at];
|
|
165
|
+
if (span.end <= maskedEnd)
|
|
166
|
+
continue;
|
|
167
|
+
start = span.start < maskedEnd ? origStart : origEnd + span.start - maskedEnd;
|
|
168
|
+
}
|
|
169
|
+
let end = span.end;
|
|
170
|
+
at = labelBefore(labels, span.end, true);
|
|
171
|
+
if (at >= 0) {
|
|
172
|
+
const [, maskedEnd, , origEnd] = labels[at];
|
|
173
|
+
end = origEnd + Math.max(0, span.end - maskedEnd);
|
|
174
|
+
}
|
|
175
|
+
placed.push({ ...span, start, end, text: text.slice(start, end) });
|
|
176
|
+
}
|
|
177
|
+
return placed;
|
|
178
|
+
}
|
|
89
179
|
/**
|
|
90
180
|
* Characters a token suffix is drawn from. No vowels, so a suffix never spells
|
|
91
181
|
* a word; no 0/1/I/O, so it survives being read back by a person; no
|
|
@@ -425,7 +515,16 @@ class EuRedact {
|
|
|
425
515
|
return (det) => `[${det.entityType}]`;
|
|
426
516
|
}
|
|
427
517
|
/**
|
|
428
|
-
*
|
|
518
|
+
* Run the rules here, then send what they left to the cloud tier.
|
|
519
|
+
*
|
|
520
|
+
* Local-first: the rules engine masks everything it can find on the caller's
|
|
521
|
+
* machine, and only that masked text is sent. The service looks for what has
|
|
522
|
+
* no shape to match on — names, employers, diagnoses — and answers with
|
|
523
|
+
* spans relative to the text it received, which are mapped back onto the
|
|
524
|
+
* original here.
|
|
525
|
+
*
|
|
526
|
+
* This is minimisation, not an exemption: what the rules miss, and the names
|
|
527
|
+
* and diagnoses the model is there to find, still travel.
|
|
429
528
|
*
|
|
430
529
|
* Options the service cannot honour throw rather than being ignored. Silently
|
|
431
530
|
* dropping one would mean returning a result that does not match what was
|
|
@@ -434,6 +533,7 @@ class EuRedact {
|
|
|
434
533
|
*/
|
|
435
534
|
async redactViaCloud(text, options) {
|
|
436
535
|
const { CloudClient } = await Promise.resolve().then(() => __importStar(require("./cloud/client.js")));
|
|
536
|
+
const { CloudError } = await Promise.resolve().then(() => __importStar(require("./cloud/errors.js")));
|
|
437
537
|
const countries = options.countries ?? null;
|
|
438
538
|
if (!countries || countries.length !== 1) {
|
|
439
539
|
throw new Error('cloud mode needs exactly one country, e.g. { countries: ["BE"] }. The ' +
|
|
@@ -451,43 +551,34 @@ class EuRedact {
|
|
|
451
551
|
if (options.referentialIntegrity) {
|
|
452
552
|
throw new Error("referentialIntegrity is not supported in cloud mode");
|
|
453
553
|
}
|
|
454
|
-
// detectDates is deliberately NOT forwarded. The service always runs its
|
|
455
|
-
// rules engine with dates on, because that is what the model was trained
|
|
456
|
-
// against; the caller's value cannot change that. It is the one ignored
|
|
457
|
-
// option that is safe to ignore — it can only cause MORE to be detected,
|
|
458
|
-
// never less, so it cannot produce under-redaction.
|
|
459
554
|
const allowed = this.allowedFor(options);
|
|
460
555
|
const domains = this.domainsFor(options);
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
}
|
|
487
|
-
[result.detections, result.exempted] = applyAllowlist(text, result.detections, opts.allowed, opts.domains);
|
|
488
|
-
const tokenMapper = opts.tokenize ? new TokenMapper(text, result.detections) : null;
|
|
489
|
-
result.redactedText = applyReplacements(text, result.detections, this.labelFor(false, tokenMapper));
|
|
490
|
-
result.tokens = tokenMapper ? tokenMapper.tokens : {};
|
|
556
|
+
const client = new CloudClient();
|
|
557
|
+
// Dates are always on, whatever detectDates says: that is the rules output
|
|
558
|
+
// the model was trained against, and a date of birth the rules can place
|
|
559
|
+
// has no reason to travel. It is the one ignored option that is safe to
|
|
560
|
+
// ignore — it can only cause MORE to be detected, never less.
|
|
561
|
+
//
|
|
562
|
+
// No allowlist and no tokens on this pass. An exemption says what the
|
|
563
|
+
// caller wants back, not what may leave; both are applied below, after the
|
|
564
|
+
// response, to the merged spans.
|
|
565
|
+
const local = this.redactRules(text, { countries, detectDates: true, cache: options.cache }, new Map(), new Map());
|
|
566
|
+
const [masked, labels] = maskForCloud(text, local.detections);
|
|
567
|
+
// The client converts the service's code-point offsets to UTF-16 units of
|
|
568
|
+
// the text it sent, so everything from here on is in one unit.
|
|
569
|
+
const remote = await client.redact(masked, { country: countries[0] });
|
|
570
|
+
// A new array throughout: `local` may be the cached rules result.
|
|
571
|
+
const [detections, exempted] = applyAllowlist(text, [...local.detections, ...ontoOriginal(remote.detections, masked, text, labels, CloudError)], allowed, domains);
|
|
572
|
+
detections.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
573
|
+
const tokenMapper = options.tokenize ? new TokenMapper(text, detections) : null;
|
|
574
|
+
return {
|
|
575
|
+
...local,
|
|
576
|
+
redactedText: applyReplacements(text, detections, this.labelFor(false, tokenMapper)),
|
|
577
|
+
detections,
|
|
578
|
+
source: "cloud",
|
|
579
|
+
tokens: tokenMapper ? tokenMapper.tokens : {},
|
|
580
|
+
exempted,
|
|
581
|
+
};
|
|
491
582
|
}
|
|
492
583
|
redact(text, options = {}) {
|
|
493
584
|
checkCountryArg(options.countries, "countries");
|
|
@@ -508,12 +599,16 @@ class EuRedact {
|
|
|
508
599
|
if (requestedMode !== "rules") {
|
|
509
600
|
throw new Error(`unknown mode ${JSON.stringify(requestedMode)}: expected "rules" or "cloud"`);
|
|
510
601
|
}
|
|
602
|
+
return this.redactRules(text, options, allowed, domains);
|
|
603
|
+
}
|
|
604
|
+
/** The local pipeline: normalise, detect, exempt, mask. */
|
|
605
|
+
redactRules(text, options, allowed, domains) {
|
|
511
606
|
if (text.length > this.maxInputLength) {
|
|
512
607
|
throw new Error(`Input text length (${text.length.toLocaleString()} chars) exceeds the maximum ` +
|
|
513
608
|
`(${this.maxInputLength.toLocaleString()} chars). Split the input or ` +
|
|
514
609
|
`increase maxInputLength when constructing EuRedact.`);
|
|
515
610
|
}
|
|
516
|
-
const { countries = null, countryHint = null, context = null, chunkOffset = 0,
|
|
611
|
+
const { countries = null, countryHint = null, context = null, chunkOffset = 0, referentialIntegrity = false, tokenize = false, detectDates = false, } = options;
|
|
517
612
|
// A context makes the result depend on evidence from other chunks, so the
|
|
518
613
|
// text no longer identifies the result. Caching is disabled rather than
|
|
519
614
|
// keyed on the context, whose contents change as chunks arrive.
|
|
@@ -531,7 +626,7 @@ class EuRedact {
|
|
|
531
626
|
// separator, and two different lists must never share a key.
|
|
532
627
|
const allowKey = (allowed.size ? JSON.stringify([...allowed.keys()].sort()) : "")
|
|
533
628
|
+ (domains.size ? "|dom=" + JSON.stringify([...domains.keys()].sort()) : "");
|
|
534
|
-
const cacheMode =
|
|
629
|
+
const cacheMode = `rules|dates=${detectDates}|hint=${hintKey}|ri=${referentialIntegrity}|tok=${tokenize}|allow=${allowKey}`;
|
|
535
630
|
let cacheKey;
|
|
536
631
|
if (cache) {
|
|
537
632
|
cacheKey = this.cache.key(normalizedText, countriesTuple, cacheMode);
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* [CLOUD EXTENSION] The cloud client.
|
|
3
3
|
*
|
|
4
|
-
* Speaks to the euRedact inference service: a
|
|
5
|
-
*
|
|
6
|
-
* service returns both the redacted document and located spans, so this
|
|
4
|
+
* Speaks to the euRedact inference service: a fine-tuned model asked only
|
|
5
|
+
* *what did the rules miss?* The service returns located spans, so this
|
|
7
6
|
* module's job is transport and translation, not detection.
|
|
8
7
|
*
|
|
8
|
+
* It sends the text it is given, as given. `redactAsync(text, { mode: "cloud" })`
|
|
9
|
+
* is what runs the rules engine first and hands this client only the masked
|
|
10
|
+
* text; call the client directly and whatever you pass is what leaves the
|
|
11
|
+
* machine. The spans that come back index the text that was sent.
|
|
12
|
+
*
|
|
9
13
|
* Three things it hides from the caller:
|
|
10
14
|
*
|
|
11
15
|
* - **Whether the document finished inside the service's sync window.** A short
|
|
@@ -26,7 +30,6 @@ export interface CloudRedactOptions {
|
|
|
26
30
|
country: string;
|
|
27
31
|
language?: string;
|
|
28
32
|
priority?: "interactive" | "batch";
|
|
29
|
-
rulesOnly?: boolean;
|
|
30
33
|
idempotencyKey?: string;
|
|
31
34
|
/** Injectable for tests. Defaults to the platform `fetch`. */
|
|
32
35
|
fetchImpl?: typeof fetch;
|
package/dist/esm/cloud/client.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* [CLOUD EXTENSION] The cloud client.
|
|
3
3
|
*
|
|
4
|
-
* Speaks to the euRedact inference service: a
|
|
5
|
-
*
|
|
6
|
-
* service returns both the redacted document and located spans, so this
|
|
4
|
+
* Speaks to the euRedact inference service: a fine-tuned model asked only
|
|
5
|
+
* *what did the rules miss?* The service returns located spans, so this
|
|
7
6
|
* module's job is transport and translation, not detection.
|
|
8
7
|
*
|
|
8
|
+
* It sends the text it is given, as given. `redactAsync(text, { mode: "cloud" })`
|
|
9
|
+
* is what runs the rules engine first and hands this client only the masked
|
|
10
|
+
* text; call the client directly and whatever you pass is what leaves the
|
|
11
|
+
* machine. The spans that come back index the text that was sent.
|
|
12
|
+
*
|
|
9
13
|
* Three things it hides from the caller:
|
|
10
14
|
*
|
|
11
15
|
* - **Whether the document finished inside the service's sync window.** A short
|
|
@@ -153,7 +157,6 @@ export class CloudClient {
|
|
|
153
157
|
country: options.country.toUpperCase(),
|
|
154
158
|
language: options.language ?? "",
|
|
155
159
|
priority: options.priority ?? "interactive",
|
|
156
|
-
rules_only: options.rulesOnly ?? false,
|
|
157
160
|
});
|
|
158
161
|
let attempt = 0;
|
|
159
162
|
for (;;) {
|