euredact 0.5.1 → 0.7.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/NOTICE +11 -0
- package/README.md +658 -45
- package/dist/cjs/cache.js +2 -8
- package/dist/cjs/cloud/account.js +180 -0
- package/dist/cjs/cloud/batches.js +469 -0
- package/dist/cjs/cloud/client.js +131 -18
- package/dist/cjs/cloud/config.js +28 -2
- package/dist/cjs/cloud/errors.js +39 -2
- package/dist/cjs/cloud/index.js +16 -1
- package/dist/cjs/index.js +9 -1
- package/dist/cjs/platform.browser.js +98 -0
- package/dist/cjs/platform.js +140 -0
- package/dist/cjs/rules/bicGleif.js +57 -0
- package/dist/cjs/rules/bicRegistry.js +37 -11
- package/dist/cjs/rules/countries/index.js +138 -48
- package/dist/cjs/rules/cues.js +29 -9
- package/dist/cjs/rules/engine.js +14 -6
- package/dist/cjs/rules/suppressors.js +418 -23
- package/dist/cjs/rules/validators.js +103 -0
- package/dist/cjs/sdk.js +150 -44
- package/dist/cjs/types.js +4 -1
- package/dist/esm/cache.js +2 -8
- package/dist/esm/cloud/account.d.ts +155 -0
- package/dist/esm/cloud/account.js +175 -0
- package/dist/esm/cloud/batches.d.ts +166 -0
- package/dist/esm/cloud/batches.js +464 -0
- package/dist/esm/cloud/client.d.ts +90 -5
- package/dist/esm/cloud/client.js +126 -26
- package/dist/esm/cloud/config.d.ts +6 -0
- package/dist/esm/cloud/config.js +27 -2
- package/dist/esm/cloud/errors.d.ts +26 -1
- package/dist/esm/cloud/errors.js +35 -1
- package/dist/esm/cloud/index.d.ts +4 -2
- package/dist/esm/cloud/index.js +4 -2
- package/dist/esm/index.d.ts +2 -2
- package/dist/esm/index.js +1 -1
- package/dist/esm/platform.browser.d.ts +27 -0
- package/dist/esm/platform.browser.js +92 -0
- package/dist/esm/platform.d.ts +20 -0
- package/dist/esm/platform.js +101 -0
- package/dist/esm/rules/bicGleif.d.ts +4 -0
- package/dist/esm/rules/bicGleif.js +53 -0
- package/dist/esm/rules/bicRegistry.d.ts +25 -11
- package/dist/esm/rules/bicRegistry.js +36 -11
- package/dist/esm/rules/countries/index.js +138 -48
- package/dist/esm/rules/cues.d.ts +1 -1
- package/dist/esm/rules/cues.js +29 -9
- package/dist/esm/rules/engine.js +14 -6
- package/dist/esm/rules/suppressors.d.ts +1 -0
- package/dist/esm/rules/suppressors.js +417 -22
- package/dist/esm/rules/validators.d.ts +14 -0
- package/dist/esm/rules/validators.js +100 -0
- package/dist/esm/sdk.d.ts +63 -19
- package/dist/esm/sdk.js +148 -44
- package/dist/esm/types.d.ts +58 -2
- package/dist/esm/types.js +4 -1
- package/package.json +12 -4
|
@@ -65,7 +65,21 @@ export declare function validateSpanishDni(candidate: string): boolean;
|
|
|
65
65
|
export declare function validateSpanishNie(candidate: string): boolean;
|
|
66
66
|
export declare function validatePortugueseNif(candidate: string): boolean;
|
|
67
67
|
export declare function validatePolishPesel(candidate: string): boolean;
|
|
68
|
+
/** Polish domestic account number (NRB): the PL IBAN without its "PL", so the IBAN's mod-97 decides it (rules-engine#93). */
|
|
69
|
+
export declare function validatePolishNrb(candidate: string): boolean;
|
|
68
70
|
export declare function validatePolishNip(candidate: string): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Polish identity card (dowód osobisty): 3 letters + 6 digits, the first digit
|
|
73
|
+
* a check digit. Letters A=10 … Z=35, weights 7,3,1,9,7,3,1,7,3: the weighted
|
|
74
|
+
* sum of all nine is 0 mod 10 exactly when the check digit is right (rules-engine#75).
|
|
75
|
+
*/
|
|
76
|
+
export declare function validatePolishIdCard(candidate: string): boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Polish REGON: 9 digits, or 14 for a local unit, each with a mod-11 check (a
|
|
79
|
+
* remainder of 10 counts as 0). A 14-digit REGON begins with its parent's valid
|
|
80
|
+
* 9-digit REGON (rules-engine#76).
|
|
81
|
+
*/
|
|
82
|
+
export declare function validatePolishRegon(candidate: string): boolean;
|
|
69
83
|
export declare function validateCzechBirthNumber(candidate: string): boolean;
|
|
70
84
|
export declare function validateRomanianCnp(candidate: string): boolean;
|
|
71
85
|
export declare function validateHungarianTaj(candidate: string): boolean;
|
|
@@ -467,10 +467,24 @@ export function validatePortugueseNif(candidate) {
|
|
|
467
467
|
const check = remainder < 2 ? 0 : 11 - remainder;
|
|
468
468
|
return parseInt(c[8]) === check;
|
|
469
469
|
}
|
|
470
|
+
// PESEL carries the century in the month field: +0 is the 1900s, +20 the 2000s,
|
|
471
|
+
// +40 the 2100s, +60 the 2200s, +80 the 1800s. Without a date check this
|
|
472
|
+
// accepted 80666603210 -- day 66 -- as NATIONAL_ID at high confidence
|
|
473
|
+
// (rules-engine#43).
|
|
470
474
|
export function validatePolishPesel(candidate) {
|
|
471
475
|
const c = candidate.replace(/[\s\-]/g, "");
|
|
472
476
|
if (c.length !== 11 || !/^\d{11}$/.test(c))
|
|
473
477
|
return false;
|
|
478
|
+
const rawMonth = parseInt(c.slice(2, 4), 10);
|
|
479
|
+
let month = null;
|
|
480
|
+
for (const off of [0, 20, 40, 60, 80]) {
|
|
481
|
+
if (rawMonth - off >= 1 && rawMonth - off <= 12) {
|
|
482
|
+
month = rawMonth - off;
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (month === null || !isRealDay(month, parseInt(c.slice(4, 6), 10)))
|
|
487
|
+
return false;
|
|
474
488
|
const d = c.split("").map(Number);
|
|
475
489
|
const weights = [1, 3, 7, 9, 1, 3, 7, 9, 1, 3];
|
|
476
490
|
let total = 0;
|
|
@@ -478,6 +492,13 @@ export function validatePolishPesel(candidate) {
|
|
|
478
492
|
total += d[i] * weights[i];
|
|
479
493
|
return d[10] === (10 - (total % 10)) % 10;
|
|
480
494
|
}
|
|
495
|
+
/** Polish domestic account number (NRB): the PL IBAN without its "PL", so the IBAN's mod-97 decides it (rules-engine#93). */
|
|
496
|
+
export function validatePolishNrb(candidate) {
|
|
497
|
+
const c = candidate.replace(/[\s\-]/g, "");
|
|
498
|
+
if (!/^\d{26}$/.test(c))
|
|
499
|
+
return false;
|
|
500
|
+
return validateIban("PL" + c);
|
|
501
|
+
}
|
|
481
502
|
export function validatePolishNip(candidate) {
|
|
482
503
|
const c = candidate.replace(/[\s\-]/g, "");
|
|
483
504
|
if (c.length !== 10 || !/^\d{10}$/.test(c))
|
|
@@ -489,18 +510,90 @@ export function validatePolishNip(candidate) {
|
|
|
489
510
|
total += d[i] * weights[i];
|
|
490
511
|
return total % 11 === d[9];
|
|
491
512
|
}
|
|
513
|
+
/**
|
|
514
|
+
* Polish identity card (dowód osobisty): 3 letters + 6 digits, the first digit
|
|
515
|
+
* a check digit. Letters A=10 … Z=35, weights 7,3,1,9,7,3,1,7,3: the weighted
|
|
516
|
+
* sum of all nine is 0 mod 10 exactly when the check digit is right (rules-engine#75).
|
|
517
|
+
*/
|
|
518
|
+
export function validatePolishIdCard(candidate) {
|
|
519
|
+
const c = candidate.replace(/\s/g, "").toUpperCase();
|
|
520
|
+
if (!/^[A-Z]{3}\d{6}$/.test(c))
|
|
521
|
+
return false;
|
|
522
|
+
const weights = [7, 3, 1, 9, 7, 3, 1, 7, 3];
|
|
523
|
+
let total = 0;
|
|
524
|
+
for (let i = 0; i < 9; i++) {
|
|
525
|
+
const ch = c[i];
|
|
526
|
+
total += (/[A-Z]/.test(ch) ? ch.charCodeAt(0) - 55 : Number(ch)) * weights[i];
|
|
527
|
+
}
|
|
528
|
+
return total % 10 === 0;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Polish REGON: 9 digits, or 14 for a local unit, each with a mod-11 check (a
|
|
532
|
+
* remainder of 10 counts as 0). A 14-digit REGON begins with its parent's valid
|
|
533
|
+
* 9-digit REGON (rules-engine#76).
|
|
534
|
+
*/
|
|
535
|
+
export function validatePolishRegon(candidate) {
|
|
536
|
+
const c = candidate.replace(/[\s\-]/g, "");
|
|
537
|
+
if (!/^\d+$/.test(c) || (c.length !== 9 && c.length !== 14))
|
|
538
|
+
return false;
|
|
539
|
+
const check = (digits, weights) => {
|
|
540
|
+
let total = 0;
|
|
541
|
+
for (let i = 0; i < weights.length; i++)
|
|
542
|
+
total += Number(digits[i]) * weights[i];
|
|
543
|
+
const rest = total % 11;
|
|
544
|
+
return (rest === 10 ? 0 : rest) === Number(digits[weights.length]);
|
|
545
|
+
};
|
|
546
|
+
if (!check(c.slice(0, 9), [8, 9, 2, 3, 4, 5, 6, 7]))
|
|
547
|
+
return false;
|
|
548
|
+
return c.length === 9 || check(c, [2, 4, 8, 5, 0, 9, 7, 3, 6, 1, 2, 4, 8]);
|
|
549
|
+
}
|
|
550
|
+
// Days per month for the rodné číslo date check. February is 29 because the
|
|
551
|
+
// century is not recoverable from a two-digit year, so a leap year cannot be
|
|
552
|
+
// ruled out -- being permissive by one day is the right direction here.
|
|
553
|
+
const RC_DAYS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
554
|
+
// The calendar month a rodné číslo month field encodes, or null. Four ranges
|
|
555
|
+
// are legal: 1-12 is a man, +50 a woman, and since 2004 +20 marks a birth on a
|
|
556
|
+
// day whose sequence numbers were exhausted (so +70 is a woman on such a day).
|
|
557
|
+
// Whether `day` exists in `month`, February permissive at 29 because a
|
|
558
|
+
// two-digit year does not reveal the century.
|
|
559
|
+
function isRealDay(month, day) {
|
|
560
|
+
return month >= 1 && month <= 12 && day >= 1 && day <= RC_DAYS[month - 1];
|
|
561
|
+
}
|
|
562
|
+
function czechMonth(raw) {
|
|
563
|
+
for (const offset of [0, 20, 50, 70]) {
|
|
564
|
+
if (raw - offset >= 1 && raw - offset <= 12)
|
|
565
|
+
return raw - offset;
|
|
566
|
+
}
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
// The date is checked, not only the checksum. Without it the validator accepted
|
|
570
|
+
// an impossible month, and a Czech mobile number is the same shape -- nine
|
|
571
|
+
// digits opening 6 or 7 -- so any mobile that happened to pass mod 11 was typed
|
|
572
|
+
// NATIONAL_ID at high confidence: 606666032 (MM=66), 778836400 (MM=88),
|
|
573
|
+
// 728990603 (MM=89). 284 per corpus pass, the largest single false-positive
|
|
574
|
+
// bucket in the evaluation (rules-engine#37).
|
|
492
575
|
export function validateCzechBirthNumber(candidate) {
|
|
493
576
|
const c = candidate.replace(/[/\s]/g, "");
|
|
494
577
|
if (!/^\d+$/.test(c) || (c.length !== 9 && c.length !== 10))
|
|
495
578
|
return false;
|
|
579
|
+
const month = czechMonth(parseInt(c.slice(2, 4), 10));
|
|
580
|
+
if (month === null)
|
|
581
|
+
return false;
|
|
582
|
+
const day = parseInt(c.slice(4, 6), 10);
|
|
583
|
+
if (day < 1 || day > RC_DAYS[month - 1])
|
|
584
|
+
return false;
|
|
496
585
|
if (c.length === 10)
|
|
497
586
|
return parseInt(c) % 11 === 0;
|
|
498
587
|
return true;
|
|
499
588
|
}
|
|
589
|
+
// S YY MM DD JJ NNN C -- the leading S carries the century, so the month needs
|
|
590
|
+
// no offset convention here (rules-engine#43).
|
|
500
591
|
export function validateRomanianCnp(candidate) {
|
|
501
592
|
const c = candidate.replace(/[\s\-]/g, "");
|
|
502
593
|
if (c.length !== 13 || !/^\d{13}$/.test(c) || !"12345678".includes(c[0]))
|
|
503
594
|
return false;
|
|
595
|
+
if (!isRealDay(parseInt(c.slice(3, 5), 10), parseInt(c.slice(5, 7), 10)))
|
|
596
|
+
return false;
|
|
504
597
|
const key = "279146358279";
|
|
505
598
|
let total = 0;
|
|
506
599
|
for (let i = 0; i < 12; i++)
|
|
@@ -547,10 +640,14 @@ export function validateCroatianOib(candidate) {
|
|
|
547
640
|
const check = (11 - product) % 10;
|
|
548
641
|
return check === parseInt(c[10]);
|
|
549
642
|
}
|
|
643
|
+
// DD MM YYY RR BBB K -- note EMSO opens with the *day*, where the Czech, Polish
|
|
644
|
+
// and Romanian forms open with the year or a century digit (rules-engine#43).
|
|
550
645
|
export function validateSlovenianEmso(candidate) {
|
|
551
646
|
const c = candidate.replace(/[\s\-]/g, "");
|
|
552
647
|
if (c.length !== 13 || !/^\d{13}$/.test(c))
|
|
553
648
|
return false;
|
|
649
|
+
if (!isRealDay(parseInt(c.slice(2, 4), 10), parseInt(c.slice(0, 2), 10)))
|
|
650
|
+
return false;
|
|
554
651
|
const d = c.split("").map(Number);
|
|
555
652
|
const weights = [7, 6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
|
|
556
653
|
let total = 0;
|
|
@@ -700,6 +797,9 @@ export const VALIDATORS = {
|
|
|
700
797
|
portuguese_nif: validatePortugueseNif,
|
|
701
798
|
polish_pesel: validatePolishPesel,
|
|
702
799
|
polish_nip: validatePolishNip,
|
|
800
|
+
polish_nrb: validatePolishNrb,
|
|
801
|
+
polish_id_card: validatePolishIdCard,
|
|
802
|
+
polish_regon: validatePolishRegon,
|
|
703
803
|
czech_birth_number: validateCzechBirthNumber,
|
|
704
804
|
romanian_cnp: validateRomanianCnp,
|
|
705
805
|
hungarian_taj: validateHungarianTaj,
|
package/dist/esm/sdk.d.ts
CHANGED
|
@@ -22,11 +22,51 @@ type LabelFor = (det: Detection, slice: string) => string;
|
|
|
22
22
|
*/
|
|
23
23
|
export declare function applyReplacements(text: string, detections: Detection[], labelFor: LabelFor): string;
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* One label in the text sent to the cloud tier: where it sits in the masked
|
|
26
|
+
* text, and the extent of the original it replaced —
|
|
27
|
+
* `[maskedStart, maskedEnd, start, end]`.
|
|
28
28
|
*/
|
|
29
|
-
export
|
|
29
|
+
export type WireLabel = [number, number, number, number];
|
|
30
|
+
/**
|
|
31
|
+
* Bracket-mask `text` and record where each label landed.
|
|
32
|
+
*
|
|
33
|
+
* Returns what is sent to the cloud tier, plus one `WireLabel` per label.
|
|
34
|
+
* Everything between labels is copied unchanged, so the labels are all it
|
|
35
|
+
* takes to carry an offset from the masked text back to the original.
|
|
36
|
+
*
|
|
37
|
+
* Always `[TYPE]`, whatever label scheme the caller asked for. That is the
|
|
38
|
+
* syntax the model was trained on, where a placeholder means "already handled,
|
|
39
|
+
* do not list it". A token or a numbered label would be read as ordinary text,
|
|
40
|
+
* so the format on the wire is a model contract and not an output option.
|
|
41
|
+
*/
|
|
42
|
+
/** @internal Shared with cloud/batches.ts. */
|
|
43
|
+
export declare function maskForCloud(text: string, detections: Detection[]): [string, WireLabel[]];
|
|
44
|
+
/**
|
|
45
|
+
* Carry the service's spans from the masked text onto the original.
|
|
46
|
+
*
|
|
47
|
+
* The service only ever saw `masked`, so its offsets index that. An offset in
|
|
48
|
+
* copied text shifts by whatever the labels before it added or removed. An
|
|
49
|
+
* offset inside a label has no counterpart in the original, so it snaps
|
|
50
|
+
* outward to the edge of the value the label replaced: an address the model
|
|
51
|
+
* reports around `[POSTAL_CODE]` covers the postal code, never half of it.
|
|
52
|
+
* Over-masking is the safe direction.
|
|
53
|
+
*
|
|
54
|
+
* A span lying wholly inside one label describes the label, not the document.
|
|
55
|
+
* It is dropped; the local detection behind the label stands.
|
|
56
|
+
*
|
|
57
|
+
* Throws `Unplaceable` when a span does not match the text that was sent. Such
|
|
58
|
+
* a span cannot be placed, and masking where it points would cover the wrong
|
|
59
|
+
* characters and leave the right ones in the clear.
|
|
60
|
+
*/
|
|
61
|
+
/** @internal Shared with cloud/batches.ts. */
|
|
62
|
+
export declare function ontoOriginal(spans: Detection[], masked: string, text: string, labels: WireLabel[], Unplaceable: new (message: string) => Error): Detection[];
|
|
63
|
+
/**
|
|
64
|
+
* Characters a token suffix is drawn from. No vowels (and no Y), so a suffix
|
|
65
|
+
* never spells a word; no 0/1/I/O, so it survives being read back by a person;
|
|
66
|
+
* no underscore, so the type prefix stays unambiguous. A, E, U and Y were in it
|
|
67
|
+
* until rules-engine#55, against this comment.
|
|
68
|
+
*/
|
|
69
|
+
export declare const TOKEN_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ23456789";
|
|
30
70
|
export declare const TOKEN_SUFFIX_LENGTH = 4;
|
|
31
71
|
/**
|
|
32
72
|
* Maps each distinct PII value in one call to a reversible token.
|
|
@@ -106,6 +146,13 @@ export interface RedactOptions {
|
|
|
106
146
|
allowlistDomains?: string[] | null;
|
|
107
147
|
detectDates?: boolean;
|
|
108
148
|
cache?: boolean;
|
|
149
|
+
/**
|
|
150
|
+
* `mode: "cloud"` only. Sent as the request's `Idempotency-Key`: the same key
|
|
151
|
+
* returns the same job instead of running (and billing) the document again,
|
|
152
|
+
* which is what a caller retrying after its own timeout needs. One is
|
|
153
|
+
* generated per call when omitted.
|
|
154
|
+
*/
|
|
155
|
+
idempotencyKey?: string;
|
|
109
156
|
}
|
|
110
157
|
export declare class EuRedact {
|
|
111
158
|
private engine;
|
|
@@ -136,7 +183,16 @@ export declare class EuRedact {
|
|
|
136
183
|
/** The label function for one call, given its output options. */
|
|
137
184
|
private labelFor;
|
|
138
185
|
/**
|
|
139
|
-
*
|
|
186
|
+
* Run the rules here, then send what they left to the cloud tier.
|
|
187
|
+
*
|
|
188
|
+
* Local-first: the rules engine masks everything it can find on the caller's
|
|
189
|
+
* machine, and only that masked text is sent. The service looks for what has
|
|
190
|
+
* no shape to match on — names, employers, diagnoses — and answers with
|
|
191
|
+
* spans relative to the text it received, which are mapped back onto the
|
|
192
|
+
* original here.
|
|
193
|
+
*
|
|
194
|
+
* This is minimisation, not an exemption: what the rules miss, and the names
|
|
195
|
+
* and diagnoses the model is there to find, still travel.
|
|
140
196
|
*
|
|
141
197
|
* Options the service cannot honour throw rather than being ignored. Silently
|
|
142
198
|
* dropping one would mean returning a result that does not match what was
|
|
@@ -144,21 +200,9 @@ export declare class EuRedact {
|
|
|
144
200
|
* under-redaction wearing a plausible face.
|
|
145
201
|
*/
|
|
146
202
|
private redactViaCloud;
|
|
147
|
-
/**
|
|
148
|
-
* Rebuild the masked text from the service's spans, in place.
|
|
149
|
-
*
|
|
150
|
-
* The service masks with bracketed labels only. Any other output option is
|
|
151
|
-
* applied here, from its spans: the service builds its `redacted_text` from
|
|
152
|
-
* exactly the spans it returns (entities it reports but cannot place are
|
|
153
|
-
* listed separately and never applied), so nothing it masked is lost by
|
|
154
|
-
* masking again from the same spans.
|
|
155
|
-
*
|
|
156
|
-
* The service reports offsets in code points and this SDK slices UTF-16
|
|
157
|
-
* units, so the slice check is what stands between an emoji in the document
|
|
158
|
-
* and a rebuilt text with the wrong characters masked.
|
|
159
|
-
*/
|
|
160
|
-
private remaskCloudResult;
|
|
161
203
|
redact(text: string, options?: RedactOptions): RedactResult;
|
|
204
|
+
/** The local pipeline: normalise, detect, exempt, mask. */
|
|
205
|
+
private redactRules;
|
|
162
206
|
/**
|
|
163
207
|
* Redact, awaiting the cloud tier when `mode: "cloud"` is asked for.
|
|
164
208
|
*
|
package/dist/esm/sdk.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomIndex } from "./platform.js";
|
|
2
2
|
import { ResultCache } from "./cache.js";
|
|
3
3
|
import { normalize, mapOffsetToOriginal } from "./normalizer.js";
|
|
4
4
|
import { RuleEngine } from "./rules/engine.js";
|
|
@@ -49,11 +49,104 @@ export function applyReplacements(text, detections, labelFor) {
|
|
|
49
49
|
return parts.join("");
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* Bracket-mask `text` and record where each label landed.
|
|
53
|
+
*
|
|
54
|
+
* Returns what is sent to the cloud tier, plus one `WireLabel` per label.
|
|
55
|
+
* Everything between labels is copied unchanged, so the labels are all it
|
|
56
|
+
* takes to carry an offset from the masked text back to the original.
|
|
57
|
+
*
|
|
58
|
+
* Always `[TYPE]`, whatever label scheme the caller asked for. That is the
|
|
59
|
+
* syntax the model was trained on, where a placeholder means "already handled,
|
|
60
|
+
* do not list it". A token or a numbered label would be read as ordinary text,
|
|
61
|
+
* so the format on the wire is a model contract and not an output option.
|
|
62
|
+
*/
|
|
63
|
+
/** @internal Shared with cloud/batches.ts. */
|
|
64
|
+
export function maskForCloud(text, detections) {
|
|
65
|
+
const parts = [];
|
|
66
|
+
const labels = [];
|
|
67
|
+
let pos = 0;
|
|
68
|
+
let out = 0;
|
|
69
|
+
for (const det of detections) {
|
|
70
|
+
const start = Math.max(det.start, pos);
|
|
71
|
+
if (det.end <= start)
|
|
72
|
+
continue;
|
|
73
|
+
const label = `[${det.entityType}]`;
|
|
74
|
+
out += start - pos;
|
|
75
|
+
parts.push(text.slice(pos, start), label);
|
|
76
|
+
labels.push([out, out + label.length, start, det.end]);
|
|
77
|
+
out += label.length;
|
|
78
|
+
pos = det.end;
|
|
79
|
+
}
|
|
80
|
+
parts.push(text.slice(pos));
|
|
81
|
+
return [parts.join(""), labels];
|
|
82
|
+
}
|
|
83
|
+
/** Index of the last label starting at or before `offset` — strictly before
|
|
84
|
+
* when `strict` — or -1. */
|
|
85
|
+
function labelBefore(labels, offset, strict) {
|
|
86
|
+
let lo = 0;
|
|
87
|
+
let hi = labels.length;
|
|
88
|
+
while (lo < hi) {
|
|
89
|
+
const mid = (lo + hi) >>> 1;
|
|
90
|
+
if (strict ? labels[mid][0] < offset : labels[mid][0] <= offset)
|
|
91
|
+
lo = mid + 1;
|
|
92
|
+
else
|
|
93
|
+
hi = mid;
|
|
94
|
+
}
|
|
95
|
+
return lo - 1;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Carry the service's spans from the masked text onto the original.
|
|
99
|
+
*
|
|
100
|
+
* The service only ever saw `masked`, so its offsets index that. An offset in
|
|
101
|
+
* copied text shifts by whatever the labels before it added or removed. An
|
|
102
|
+
* offset inside a label has no counterpart in the original, so it snaps
|
|
103
|
+
* outward to the edge of the value the label replaced: an address the model
|
|
104
|
+
* reports around `[POSTAL_CODE]` covers the postal code, never half of it.
|
|
105
|
+
* Over-masking is the safe direction.
|
|
106
|
+
*
|
|
107
|
+
* A span lying wholly inside one label describes the label, not the document.
|
|
108
|
+
* It is dropped; the local detection behind the label stands.
|
|
109
|
+
*
|
|
110
|
+
* Throws `Unplaceable` when a span does not match the text that was sent. Such
|
|
111
|
+
* a span cannot be placed, and masking where it points would cover the wrong
|
|
112
|
+
* characters and leave the right ones in the clear.
|
|
113
|
+
*/
|
|
114
|
+
/** @internal Shared with cloud/batches.ts. */
|
|
115
|
+
export function ontoOriginal(spans, masked, text, labels, Unplaceable) {
|
|
116
|
+
const placed = [];
|
|
117
|
+
for (const span of spans) {
|
|
118
|
+
if (!(0 <= span.start && span.start <= span.end && span.end <= masked.length) ||
|
|
119
|
+
masked.slice(span.start, span.end) !== span.text) {
|
|
120
|
+
throw new Unplaceable("span offsets do not match the text that was sent; cannot place the " +
|
|
121
|
+
"service's detections in the document");
|
|
122
|
+
}
|
|
123
|
+
if (span.start === span.end)
|
|
124
|
+
continue;
|
|
125
|
+
let start = span.start;
|
|
126
|
+
let at = labelBefore(labels, span.start, false);
|
|
127
|
+
if (at >= 0) {
|
|
128
|
+
const [, maskedEnd, origStart, origEnd] = labels[at];
|
|
129
|
+
if (span.end <= maskedEnd)
|
|
130
|
+
continue;
|
|
131
|
+
start = span.start < maskedEnd ? origStart : origEnd + span.start - maskedEnd;
|
|
132
|
+
}
|
|
133
|
+
let end = span.end;
|
|
134
|
+
at = labelBefore(labels, span.end, true);
|
|
135
|
+
if (at >= 0) {
|
|
136
|
+
const [, maskedEnd, , origEnd] = labels[at];
|
|
137
|
+
end = origEnd + Math.max(0, span.end - maskedEnd);
|
|
138
|
+
}
|
|
139
|
+
placed.push({ ...span, start, end, text: text.slice(start, end) });
|
|
140
|
+
}
|
|
141
|
+
return placed;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Characters a token suffix is drawn from. No vowels (and no Y), so a suffix
|
|
145
|
+
* never spells a word; no 0/1/I/O, so it survives being read back by a person;
|
|
146
|
+
* no underscore, so the type prefix stays unambiguous. A, E, U and Y were in it
|
|
147
|
+
* until rules-engine#55, against this comment.
|
|
55
148
|
*/
|
|
56
|
-
export const TOKEN_ALPHABET = "
|
|
149
|
+
export const TOKEN_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ23456789";
|
|
57
150
|
export const TOKEN_SUFFIX_LENGTH = 4;
|
|
58
151
|
const TOKEN_MAX_DRAWS = 100;
|
|
59
152
|
function escapeRegExp(s) {
|
|
@@ -101,7 +194,7 @@ export class TokenMapper {
|
|
|
101
194
|
}
|
|
102
195
|
let suffix = "";
|
|
103
196
|
for (let i = 0; i < TOKEN_SUFFIX_LENGTH; i++) {
|
|
104
|
-
suffix += TOKEN_ALPHABET[
|
|
197
|
+
suffix += TOKEN_ALPHABET[randomIndex(TOKEN_ALPHABET.length)];
|
|
105
198
|
}
|
|
106
199
|
token = prefix + suffix;
|
|
107
200
|
} while (this.taken.has(token));
|
|
@@ -386,7 +479,16 @@ export class EuRedact {
|
|
|
386
479
|
return (det) => `[${det.entityType}]`;
|
|
387
480
|
}
|
|
388
481
|
/**
|
|
389
|
-
*
|
|
482
|
+
* Run the rules here, then send what they left to the cloud tier.
|
|
483
|
+
*
|
|
484
|
+
* Local-first: the rules engine masks everything it can find on the caller's
|
|
485
|
+
* machine, and only that masked text is sent. The service looks for what has
|
|
486
|
+
* no shape to match on — names, employers, diagnoses — and answers with
|
|
487
|
+
* spans relative to the text it received, which are mapped back onto the
|
|
488
|
+
* original here.
|
|
489
|
+
*
|
|
490
|
+
* This is minimisation, not an exemption: what the rules miss, and the names
|
|
491
|
+
* and diagnoses the model is there to find, still travel.
|
|
390
492
|
*
|
|
391
493
|
* Options the service cannot honour throw rather than being ignored. Silently
|
|
392
494
|
* dropping one would mean returning a result that does not match what was
|
|
@@ -395,6 +497,7 @@ export class EuRedact {
|
|
|
395
497
|
*/
|
|
396
498
|
async redactViaCloud(text, options) {
|
|
397
499
|
const { CloudClient } = await import("./cloud/client.js");
|
|
500
|
+
const { CloudError } = await import("./cloud/errors.js");
|
|
398
501
|
const countries = options.countries ?? null;
|
|
399
502
|
if (!countries || countries.length !== 1) {
|
|
400
503
|
throw new Error('cloud mode needs exactly one country, e.g. { countries: ["BE"] }. The ' +
|
|
@@ -412,43 +515,37 @@ export class EuRedact {
|
|
|
412
515
|
if (options.referentialIntegrity) {
|
|
413
516
|
throw new Error("referentialIntegrity is not supported in cloud mode");
|
|
414
517
|
}
|
|
415
|
-
// detectDates is deliberately NOT forwarded. The service always runs its
|
|
416
|
-
// rules engine with dates on, because that is what the model was trained
|
|
417
|
-
// against; the caller's value cannot change that. It is the one ignored
|
|
418
|
-
// option that is safe to ignore — it can only cause MORE to be detected,
|
|
419
|
-
// never less, so it cannot produce under-redaction.
|
|
420
518
|
const allowed = this.allowedFor(options);
|
|
421
519
|
const domains = this.domainsFor(options);
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
result.tokens = tokenMapper ? tokenMapper.tokens : {};
|
|
520
|
+
const client = new CloudClient();
|
|
521
|
+
// Dates are always on, whatever detectDates says: that is the rules output
|
|
522
|
+
// the model was trained against, and a date of birth the rules can place
|
|
523
|
+
// has no reason to travel. It is the one ignored option that is safe to
|
|
524
|
+
// ignore — it can only cause MORE to be detected, never less.
|
|
525
|
+
//
|
|
526
|
+
// No allowlist and no tokens on this pass. An exemption says what the
|
|
527
|
+
// caller wants back, not what may leave; both are applied below, after the
|
|
528
|
+
// response, to the merged spans.
|
|
529
|
+
const local = this.redactRules(text, { countries, detectDates: true, cache: options.cache }, new Map(), new Map());
|
|
530
|
+
const [masked, labels] = maskForCloud(text, local.detections);
|
|
531
|
+
// The client converts the service's code-point offsets to UTF-16 units of
|
|
532
|
+
// the text it sent, so everything from here on is in one unit.
|
|
533
|
+
const remote = await client.redact(masked, {
|
|
534
|
+
country: countries[0], idempotencyKey: options.idempotencyKey,
|
|
535
|
+
});
|
|
536
|
+
// A new array throughout: `local` may be the cached rules result.
|
|
537
|
+
const [detections, exempted] = applyAllowlist(text, [...local.detections, ...ontoOriginal(remote.detections, masked, text, labels, CloudError)], allowed, domains);
|
|
538
|
+
detections.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
539
|
+
const tokenMapper = options.tokenize ? new TokenMapper(text, detections) : null;
|
|
540
|
+
return {
|
|
541
|
+
...local,
|
|
542
|
+
redactedText: applyReplacements(text, detections, this.labelFor(false, tokenMapper)),
|
|
543
|
+
detections,
|
|
544
|
+
source: "cloud",
|
|
545
|
+
tokens: tokenMapper ? tokenMapper.tokens : {},
|
|
546
|
+
exempted,
|
|
547
|
+
...(remote.cloud ? { cloud: remote.cloud } : {}),
|
|
548
|
+
};
|
|
452
549
|
}
|
|
453
550
|
redact(text, options = {}) {
|
|
454
551
|
checkCountryArg(options.countries, "countries");
|
|
@@ -469,12 +566,19 @@ export class EuRedact {
|
|
|
469
566
|
if (requestedMode !== "rules") {
|
|
470
567
|
throw new Error(`unknown mode ${JSON.stringify(requestedMode)}: expected "rules" or "cloud"`);
|
|
471
568
|
}
|
|
569
|
+
if (options.idempotencyKey !== undefined) {
|
|
570
|
+
throw new Error('idempotencyKey applies to mode: "cloud" only: a rules-only redaction makes no request to deduplicate');
|
|
571
|
+
}
|
|
572
|
+
return this.redactRules(text, options, allowed, domains);
|
|
573
|
+
}
|
|
574
|
+
/** The local pipeline: normalise, detect, exempt, mask. */
|
|
575
|
+
redactRules(text, options, allowed, domains) {
|
|
472
576
|
if (text.length > this.maxInputLength) {
|
|
473
577
|
throw new Error(`Input text length (${text.length.toLocaleString()} chars) exceeds the maximum ` +
|
|
474
578
|
`(${this.maxInputLength.toLocaleString()} chars). Split the input or ` +
|
|
475
579
|
`increase maxInputLength when constructing EuRedact.`);
|
|
476
580
|
}
|
|
477
|
-
const { countries = null, countryHint = null, context = null, chunkOffset = 0,
|
|
581
|
+
const { countries = null, countryHint = null, context = null, chunkOffset = 0, referentialIntegrity = false, tokenize = false, detectDates = false, } = options;
|
|
478
582
|
// A context makes the result depend on evidence from other chunks, so the
|
|
479
583
|
// text no longer identifies the result. Caching is disabled rather than
|
|
480
584
|
// keyed on the context, whose contents change as chunks arrive.
|
|
@@ -492,7 +596,7 @@ export class EuRedact {
|
|
|
492
596
|
// separator, and two different lists must never share a key.
|
|
493
597
|
const allowKey = (allowed.size ? JSON.stringify([...allowed.keys()].sort()) : "")
|
|
494
598
|
+ (domains.size ? "|dom=" + JSON.stringify([...domains.keys()].sort()) : "");
|
|
495
|
-
const cacheMode =
|
|
599
|
+
const cacheMode = `rules|dates=${detectDates}|hint=${hintKey}|ri=${referentialIntegrity}|tok=${tokenize}|allow=${allowKey}`;
|
|
496
600
|
let cacheKey;
|
|
497
601
|
if (cache) {
|
|
498
602
|
cacheKey = this.cache.key(normalizedText, countriesTuple, cacheMode);
|
package/dist/esm/types.d.ts
CHANGED
|
@@ -69,7 +69,8 @@ export declare enum EntityType {
|
|
|
69
69
|
BIOMETRIC_REF = "BIOMETRIC_REF",
|
|
70
70
|
FINANCIAL_AMOUNT = "FINANCIAL_AMOUNT",
|
|
71
71
|
QUASI_IDENTIFIER = "QUASI_IDENTIFIER",
|
|
72
|
-
|
|
72
|
+
/** Legacy alias of SECRET: every credential, the model's included, is SECRET (rules-engine#111). */
|
|
73
|
+
CREDENTIAL = "SECRET",
|
|
73
74
|
URL = "URL",
|
|
74
75
|
OTHER = "OTHER"
|
|
75
76
|
}
|
|
@@ -116,7 +117,13 @@ export interface Detection {
|
|
|
116
117
|
text: string;
|
|
117
118
|
source: DetectionSource;
|
|
118
119
|
country: string | null;
|
|
119
|
-
|
|
120
|
+
/**
|
|
121
|
+
* How the type was arrived at: "high", "medium" or "low" for a rules
|
|
122
|
+
* detection. `null` for a span the cloud model found and reported no
|
|
123
|
+
* confidence for: the model emits none today, and "high" would claim the
|
|
124
|
+
* checksum-backed evidence a rules detection has (rules-engine#108).
|
|
125
|
+
*/
|
|
126
|
+
confidence: string | null;
|
|
120
127
|
/**
|
|
121
128
|
* How strongly the document supports {@link Detection.country}, in [0, 1].
|
|
122
129
|
*
|
|
@@ -151,6 +158,52 @@ export interface Exemption {
|
|
|
151
158
|
/** `"value"` for the literal allowlist, `"domain"` for allowlistDomains. */
|
|
152
159
|
ruleKind: "value" | "domain";
|
|
153
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* One reason a cloud request cost what it did. `code` is one of the service's
|
|
163
|
+
* factor codes (`rules_only`, `document_length`, `instructions`,
|
|
164
|
+
* `dense_document`, `special_category_check`, `long_document`, `batch_rate`),
|
|
165
|
+
* kept as a string so a code added later on the service still arrives.
|
|
166
|
+
*/
|
|
167
|
+
export interface UsageFactor {
|
|
168
|
+
code: string;
|
|
169
|
+
/** One sentence explaining the factor, written by the service. */
|
|
170
|
+
detail: string;
|
|
171
|
+
/** `special_category_check` only: the kinds of data that triggered it. */
|
|
172
|
+
types?: string[];
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* What a cloud request cost, and why (rules-engine#89). A total and its
|
|
176
|
+
* reasons, never a cost per step; `credits` is `round(tokens * billingRate)`,
|
|
177
|
+
* the formula the service debits by.
|
|
178
|
+
*/
|
|
179
|
+
export interface Usage {
|
|
180
|
+
tokens: number;
|
|
181
|
+
billingRate: number;
|
|
182
|
+
credits: number;
|
|
183
|
+
factors: UsageFactor[];
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Something the model found that could not be placed in the document: the
|
|
187
|
+
* service could not match it to a position in what it was sent, so nothing was
|
|
188
|
+
* masked for it. Reported so a caller can decide, rather than never being told.
|
|
189
|
+
*/
|
|
190
|
+
export interface Unlocated {
|
|
191
|
+
text: string;
|
|
192
|
+
entityType: EntityType | string;
|
|
193
|
+
}
|
|
194
|
+
/** What the cloud service said about a request, beside its spans (rules-engine#89). */
|
|
195
|
+
export interface CloudInfo {
|
|
196
|
+
/** The service's job id: for support, for `Jobs.retrieve`, and to recognise an
|
|
197
|
+
* idempotent replay. `null` for a batch document. */
|
|
198
|
+
jobId: string | null;
|
|
199
|
+
/** The public name of the model that answered, `null` when only rules ran. */
|
|
200
|
+
modelVersion: string | null;
|
|
201
|
+
/** What the request cost and why. Absent when the service does not report it
|
|
202
|
+
* (older versions, batch documents) or reported it malformed. */
|
|
203
|
+
usage?: Usage;
|
|
204
|
+
/** What the model found but could not place in the text it was sent. */
|
|
205
|
+
unlocated: Unlocated[];
|
|
206
|
+
}
|
|
154
207
|
export interface RedactResult {
|
|
155
208
|
redactedText: string;
|
|
156
209
|
detections: Detection[];
|
|
@@ -188,6 +241,9 @@ export interface RedactResult {
|
|
|
188
241
|
/** Detections the allowlist kept out of the output, with the rule that
|
|
189
242
|
* matched. Empty unless an allowlist was in force. */
|
|
190
243
|
exempted: Exemption[];
|
|
244
|
+
/** What the cloud service reported beside the spans: job id, model, cost and
|
|
245
|
+
* anything it could not place. Absent on a rules-only result. */
|
|
246
|
+
cloud?: CloudInfo;
|
|
191
247
|
}
|
|
192
248
|
export interface PatternDef {
|
|
193
249
|
entityType: EntityType | string;
|
package/dist/esm/types.js
CHANGED
|
@@ -75,7 +75,8 @@ export var EntityType;
|
|
|
75
75
|
EntityType["BIOMETRIC_REF"] = "BIOMETRIC_REF";
|
|
76
76
|
EntityType["FINANCIAL_AMOUNT"] = "FINANCIAL_AMOUNT";
|
|
77
77
|
EntityType["QUASI_IDENTIFIER"] = "QUASI_IDENTIFIER";
|
|
78
|
-
|
|
78
|
+
/** Legacy alias of SECRET: every credential, the model's included, is SECRET (rules-engine#111). */
|
|
79
|
+
EntityType["CREDENTIAL"] = "SECRET";
|
|
79
80
|
EntityType["URL"] = "URL";
|
|
80
81
|
EntityType["OTHER"] = "OTHER";
|
|
81
82
|
})(EntityType || (EntityType = {}));
|
|
@@ -89,6 +90,8 @@ export const LEGACY_TYPE_ALIASES = {
|
|
|
89
90
|
NAME: "PERSON_NAME",
|
|
90
91
|
STREET_ADDRESS: "ADDRESS",
|
|
91
92
|
NATIONALITY_ETHNICITY: "SENSITIVE_ATTRIBUTE",
|
|
93
|
+
// The model labels prose credentials CREDENTIAL; the SDK reports every credential as SECRET (rules-engine#111).
|
|
94
|
+
CREDENTIAL: "SECRET",
|
|
92
95
|
};
|
|
93
96
|
/**
|
|
94
97
|
* Resolve a possibly-legacy type name to its canonical form.
|