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
package/README.md
CHANGED
|
@@ -370,6 +370,85 @@ masked is unchanged either way.
|
|
|
370
370
|
|
|
371
371
|
For custom patterns, `entityType` is a plain string (e.g. `"EMPLOYEE_ID"`).
|
|
372
372
|
|
|
373
|
+
## Batch processing and concurrency
|
|
374
|
+
|
|
375
|
+
`redactBatch` loads the country configurations once instead of per document,
|
|
376
|
+
which is the whole reason to prefer it to a loop:
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
import { redactBatch } from "euredact";
|
|
380
|
+
|
|
381
|
+
const docs = ["Mijn BSN is 111222333.", "IBAN NL91ABNA0417164300."];
|
|
382
|
+
|
|
383
|
+
redactBatch(docs, { countries: ["NL"] }).map((r) => r.redactedText);
|
|
384
|
+
// [ 'Mijn BSN is [NATIONAL_ID].', 'IBAN [BANK_ACCOUNT].' ]
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
`redactBatch` is **synchronous**, and deliberately so: the rules engine is
|
|
388
|
+
CPU-bound, so wrapping it in a promise would add scheduling without adding
|
|
389
|
+
parallelism. There is no `redactBatchAsync`. Where the Python SDK offers
|
|
390
|
+
`aredact_batch(..., max_concurrency=n)` — it can offload to a thread pool —
|
|
391
|
+
this SDK has one thread, so the honest equivalent is either `redactBatch` on the
|
|
392
|
+
main thread or real workers:
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
// Keep the event loop responsive across a large batch.
|
|
396
|
+
import { redactBatch } from "euredact";
|
|
397
|
+
|
|
398
|
+
async function* redactInChunks(docs: string[], size = 200) {
|
|
399
|
+
for (let i = 0; i < docs.length; i += size) {
|
|
400
|
+
yield redactBatch(docs.slice(i, i + size), { countries: ["NL"] });
|
|
401
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
For genuine parallelism use `node:worker_threads` and give each worker its own
|
|
407
|
+
`EuRedact`; for true throughput at scale that is the only thing that helps,
|
|
408
|
+
because the cost is regex matching rather than waiting.
|
|
409
|
+
|
|
410
|
+
`redactAsync` is for the **cloud tier**, not for parallelism — it exists because
|
|
411
|
+
a network call cannot be synchronous. With `mode: "rules"` it resolves
|
|
412
|
+
immediately with exactly what `redact()` returns. Cloud calls *are* I/O, so
|
|
413
|
+
those parallelise properly:
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
import { redactAsync } from "euredact";
|
|
417
|
+
|
|
418
|
+
const results = await Promise.all(
|
|
419
|
+
docs.map((d) => redactAsync(d, { countries: ["BE"], mode: "cloud" })),
|
|
420
|
+
);
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
Bound that yourself if the batch is large — the service enforces quotas and will
|
|
424
|
+
answer `429`, which the client honours via `Retry-After`.
|
|
425
|
+
|
|
426
|
+
### Reuse one instance
|
|
427
|
+
|
|
428
|
+
The module-level functions share a single hidden instance, which is usually what
|
|
429
|
+
you want. Construct your own when the configuration must be per-tenant: custom
|
|
430
|
+
patterns, an allowlist and the result cache all live on the instance.
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
import { EuRedact } from "euredact";
|
|
434
|
+
|
|
435
|
+
const engine = new EuRedact({ allowlist: ["ACME Corporation"] });
|
|
436
|
+
engine.addCustomPattern("CASE_REF", String.raw`CASE-\d{8}`);
|
|
437
|
+
|
|
438
|
+
engine.redactBatch(
|
|
439
|
+
["ACME Corporation, ref CASE-20260401, BSN 111222333."],
|
|
440
|
+
{ countries: ["NL"] },
|
|
441
|
+
)[0].redactedText;
|
|
442
|
+
// 'ACME Corporation, ref [CASE_REF], BSN [NATIONAL_ID].'
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
### Tokens and referential labels do not span a batch
|
|
446
|
+
|
|
447
|
+
`tokenize: true` mints tokens **per call**, so item 3 of a batch does not share a
|
|
448
|
+
token with item 1 even for the same value. If two documents must agree on a
|
|
449
|
+
label, redact them as one text, or use `referentialIntegrity: true`, whose
|
|
450
|
+
`TYPE_n` numbering is per instance rather than per call.
|
|
451
|
+
|
|
373
452
|
## Cloud tier
|
|
374
453
|
|
|
375
454
|
> **Status: private alpha.** The cloud tier is in closed testing — it is **not**
|
|
@@ -412,13 +491,105 @@ seen a chunk boundary.
|
|
|
412
491
|
|
|
413
492
|
Options the service cannot honour reject rather than being ignored: multiple
|
|
414
493
|
`countries`, `countryHint`, `context`/`chunkOffset` and `referentialIntegrity`.
|
|
415
|
-
`tokenize`, `allowlist` and `allowlistDomains` are honoured: the SDK applies
|
|
416
|
-
|
|
494
|
+
`tokenize`, `allowlist` and `allowlistDomains` are honoured: the SDK applies
|
|
495
|
+
them locally, after the response, to its own spans and the service's.
|
|
496
|
+
|
|
497
|
+
`CloudClient` is the transport underneath. It sends exactly the text it is
|
|
498
|
+
given and returns spans relative to it: the local masking happens in
|
|
499
|
+
`redactAsync()`, not in the client. Use it directly only with text you have
|
|
500
|
+
already masked.
|
|
417
501
|
|
|
418
502
|
The package stays **zero-dependency** — the client uses the platform's own
|
|
419
503
|
`fetch`. Node 18+ provides one; on Node 16 the rules engine is unaffected and a
|
|
420
504
|
`fetchImpl` can be supplied.
|
|
421
505
|
|
|
506
|
+
### What leaves your machine
|
|
507
|
+
|
|
508
|
+
Be precise about this, because it is the question a security review asks first.
|
|
509
|
+
|
|
510
|
+
In `mode: "cloud"` the rules engine runs **on your machine first**, and only
|
|
511
|
+
the text it leaves behind is sent to the service over TLS. Every value it finds
|
|
512
|
+
is already a `[TYPE]` placeholder by the time the request is built:
|
|
513
|
+
|
|
514
|
+
```
|
|
515
|
+
your text IBAN NL91 ABNA 0417 1643 00 belongs to Nick Bols, tel +31 6 12345678
|
|
516
|
+
│ rules engine, on your machine
|
|
517
|
+
what is sent IBAN [BANK_ACCOUNT] belongs to Nick Bols, tel [PHONE]
|
|
518
|
+
│ TLS ──▶ service (model) ──▶ spans relative to the masked text
|
|
519
|
+
what you get IBAN [BANK_ACCOUNT] belongs to [PERSON_NAME], tel [PHONE]
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
The request body is that one `text` field plus the country. No list of types,
|
|
523
|
+
no offsets and no original values travel beside it. The service answers with
|
|
524
|
+
spans that index the masked text, and the SDK maps them back onto your
|
|
525
|
+
original, so `detections` always index the document you passed in. Service offsets count
|
|
526
|
+
code points and JavaScript slices UTF-16 units; the client converts.
|
|
527
|
+
|
|
528
|
+
**This is minimisation, not an exemption.** Three things still leave:
|
|
529
|
+
|
|
530
|
+
- what the model is there to find — names, employers, job titles, diagnoses;
|
|
531
|
+
- anything the rules engine missed, such as an identifier in a format it does
|
|
532
|
+
not know;
|
|
533
|
+
- the surrounding prose.
|
|
534
|
+
|
|
535
|
+
So the service still processes personal data on your behalf, and a DPIA should
|
|
536
|
+
say so. What it no longer receives is every IBAN, national ID, phone number and
|
|
537
|
+
email address the local engine could place.
|
|
538
|
+
|
|
539
|
+
Three things behave differently here than in rules mode:
|
|
540
|
+
|
|
541
|
+
- `detectDates` has no effect. The local pass always runs with dates on,
|
|
542
|
+
because that is the rules output the model was trained against. It can only
|
|
543
|
+
cause more to be masked, never less.
|
|
544
|
+
- `tokenize` and the allowlists are applied locally, after the response, and do
|
|
545
|
+
not change what is sent: placeholders on the wire are always `[TYPE]`, and an
|
|
546
|
+
allowlisted value is masked in the request and put back in your result.
|
|
547
|
+
- [Custom patterns](#custom-patterns) run in the local pass, so a value only
|
|
548
|
+
you know how to recognise is masked before the request too.
|
|
549
|
+
|
|
550
|
+
Every span the service returns is checked against the text that was sent. One
|
|
551
|
+
that does not match raises `CloudError` rather than being placed by guesswork.
|
|
552
|
+
|
|
553
|
+
### Keeping identifiers local
|
|
554
|
+
|
|
555
|
+
`mode: "cloud"` does this for you with our model. The same pattern works with
|
|
556
|
+
any other model — your own, or a general-purpose LLM — and that is what
|
|
557
|
+
`tokenize` is for:
|
|
558
|
+
|
|
559
|
+
```ts
|
|
560
|
+
import { redact, restore } from "euredact";
|
|
561
|
+
|
|
562
|
+
const prompt = "Stuur een mail naar Bas Verhoeven (bas@example.nl) over NL91ABNA0417164300.";
|
|
563
|
+
|
|
564
|
+
// 1. Mask locally. Nothing has left the process.
|
|
565
|
+
const local = redact(prompt, { countries: ["NL"], tokenize: true });
|
|
566
|
+
local.redactedText;
|
|
567
|
+
// 'Stuur een mail naar Bas Verhoeven (EMAIL_S5SH) over BANK_ACCOUNT_5XXR.'
|
|
568
|
+
local.tokens;
|
|
569
|
+
// { BANK_ACCOUNT_5XXR: 'NL91ABNA0417164300', EMAIL_S5SH: 'bas@example.nl' }
|
|
570
|
+
|
|
571
|
+
// 2. Send only the masked text to any model, ours or someone else's.
|
|
572
|
+
const answer = await callYourModel(local.redactedText);
|
|
573
|
+
|
|
574
|
+
// 3. Put the real values back locally.
|
|
575
|
+
restore(answer, local.tokens);
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
The token suffixes are random per call, so yours will differ.
|
|
579
|
+
|
|
580
|
+
**Read that output carefully: `Bas Verhoeven` is still there.** That is the whole
|
|
581
|
+
trade-off and it is why the cloud tier exists. The local engine masks what has a
|
|
582
|
+
shape — the IBAN and the address — and cannot mask a name, an employer or a
|
|
583
|
+
diagnosis, because it cannot find them. So this pattern keeps every identifier
|
|
584
|
+
the engine detects inside your process and sends the prose, names included.
|
|
585
|
+
`mode: "cloud"` sends that same masked prose to a model trained to find the
|
|
586
|
+
names in it.
|
|
587
|
+
|
|
588
|
+
Either way the prose leaves. If names must not leave your infrastructure
|
|
589
|
+
either, no hosted model can help; run one yourself against
|
|
590
|
+
`local.redactedText`.
|
|
591
|
+
|
|
592
|
+
|
|
422
593
|
## `NAME` is now `PERSON_NAME`
|
|
423
594
|
|
|
424
595
|
The canonical type name is `PERSON_NAME`; `NAME` is a legacy alias, exactly as
|
|
@@ -431,6 +602,82 @@ matching the *string* `"NAME"` should be updated; `LEGACY_TYPE_ALIASES`
|
|
|
431
602
|
publishes the mapping, and `STREET_ADDRESS` → `ADDRESS` and
|
|
432
603
|
`NATIONALITY_ETHNICITY` → `SENSITIVE_ATTRIBUTE` are recognised the same way.
|
|
433
604
|
|
|
605
|
+
## What `countries` actually controls
|
|
606
|
+
|
|
607
|
+
This is the parameter most often misread, and reading it wrongly leads to
|
|
608
|
+
under-redaction in exactly the case you care about — a foreign identifier in a
|
|
609
|
+
domestic document. So, precisely:
|
|
610
|
+
|
|
611
|
+
> `countries` decides how a detection is **attributed and scored**.
|
|
612
|
+
> It does **not** decide what gets **found**.
|
|
613
|
+
|
|
614
|
+
Country-specific patterns for every supported country run on every document,
|
|
615
|
+
whatever you declare. Declaring `countries: ["NL"]` does not switch the Belgian
|
|
616
|
+
patterns off; it says "this document is Dutch", and anything Belgian that turns
|
|
617
|
+
up is still detected and still masked — it is just flagged as sitting outside
|
|
618
|
+
what you declared.
|
|
619
|
+
|
|
620
|
+
The same Belgian national number, under four different calls:
|
|
621
|
+
|
|
622
|
+
```ts
|
|
623
|
+
import { redact } from "euredact";
|
|
624
|
+
|
|
625
|
+
const text = "Rijksregisternummer 85.07.30-033.61 van onze klant.";
|
|
626
|
+
|
|
627
|
+
for (const options of [{ countries: ["NL"] },
|
|
628
|
+
{ countries: ["BE"] },
|
|
629
|
+
{},
|
|
630
|
+
{ countries: ["NL"], countryHint: ["BE"] }]) {
|
|
631
|
+
const result = redact(text, options);
|
|
632
|
+
const [detection] = result.detections;
|
|
633
|
+
console.log(result.redactedText, "|", result.detectionMode,
|
|
634
|
+
"| country:", detection.country,
|
|
635
|
+
"| countryConfidence:", detection.countryConfidence,
|
|
636
|
+
"| outOfScope:", detection.outOfScope);
|
|
637
|
+
}
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
```
|
|
641
|
+
Rijksregisternummer [NATIONAL_ID] van onze klant. | declared | country: BE | countryConfidence: 0 | outOfScope: true
|
|
642
|
+
Rijksregisternummer [NATIONAL_ID] van onze klant. | declared | country: BE | countryConfidence: 0.8807970779778823 | outOfScope: false
|
|
643
|
+
Rijksregisternummer [NATIONAL_ID] van onze klant. | inferred | country: BE | countryConfidence: 0 | outOfScope: false
|
|
644
|
+
Rijksregisternummer [NATIONAL_ID] van onze klant. | declared | country: BE | countryConfidence: 0.8807970779778823 | outOfScope: true
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
**The masked output is byte-identical in all four.** What moved is the metadata.
|
|
648
|
+
|
|
649
|
+
| You pass | Effect on what is found | Effect on labelling |
|
|
650
|
+
|---|---|---|
|
|
651
|
+
| `countries: ["NL"]` | None — every country's patterns still run | Attributed normally; a non-Dutch hit gets `outOfScope: true` and `countryConfidence: 0` |
|
|
652
|
+
| `countries: ["BE"]` | None | The Belgian hit is corroborated: `countryConfidence` ~0.88, `outOfScope: false` |
|
|
653
|
+
| `countries` omitted | None | `detectionMode: "inferred"`; the country is inferred from the document and nothing is out of scope, because nothing was declared |
|
|
654
|
+
| `countryHint: ["BE"]` | None | A *prior*: it corroborates Belgian attribution **without** widening or narrowing scope, so `out_of_scope` still reflects `countries` alone |
|
|
655
|
+
|
|
656
|
+
### How to use each one
|
|
657
|
+
|
|
658
|
+
- **`countries: [...]`** — you know where the document is from. Use it. It
|
|
659
|
+
improves attribution and makes `out_of_scope` meaningful, and it is the
|
|
660
|
+
cheapest path because country inference is skipped.
|
|
661
|
+
- **`countries` omitted** — you do not know. The engine infers from the document
|
|
662
|
+
and reports `inferredCountries` with confidences.
|
|
663
|
+
- **`countryHint: [...]`** — you have a weak signal (the customer's billing
|
|
664
|
+
country, the mailbox a document arrived in) that should resolve ambiguity
|
|
665
|
+
without declaring scope. Added in 0.3.2 precisely so that a hint could not be
|
|
666
|
+
mistaken for a scope restriction.
|
|
667
|
+
|
|
668
|
+
### Why it works this way
|
|
669
|
+
|
|
670
|
+
Until 0.3.2, `countries` *did* gate detection, and 0.3.3 fixed the remaining
|
|
671
|
+
case where it "could change which spans were found, not just how they were
|
|
672
|
+
labelled". Both were changed deliberately: a redaction library that hides a
|
|
673
|
+
Belgian national number because you told it the document was Dutch has failed
|
|
674
|
+
at the only job it has. Scoping is a reporting concern; masking is not.
|
|
675
|
+
|
|
676
|
+
The practical consequence: **do not use `countries` as a filter.** If you only
|
|
677
|
+
want Dutch detections in your output, filter on
|
|
678
|
+
`detection.country === "NL"` or on `detection.outOfScope` after the call —
|
|
679
|
+
the value was still masked in the text either way.
|
|
680
|
+
|
|
434
681
|
## Country codes
|
|
435
682
|
|
|
436
683
|
`countries` accepts **ISO 3166-1 alpha-2** codes. The two EU/VAT spellings are
|
|
@@ -756,9 +1003,9 @@ itself be redacted without `restore()` putting the wrong value back.
|
|
|
756
1003
|
`restore()` replaces every occurrence, including a token an LLM glued to other
|
|
757
1004
|
characters (`EMAIL_P4RTs`) — leaving a token behind is the worse failure.
|
|
758
1005
|
|
|
759
|
-
Works in cloud mode via `redactAsync`:
|
|
760
|
-
|
|
761
|
-
|
|
1006
|
+
Works in cloud mode via `redactAsync`: tokens are minted locally, after the
|
|
1007
|
+
response, over the local spans and the service's. What is sent to the service
|
|
1008
|
+
always carries `[TYPE]` placeholders, never tokens.
|
|
762
1009
|
|
|
763
1010
|
## Allowlist
|
|
764
1011
|
|
|
@@ -926,20 +1173,22 @@ Measured on the same 611 documents, this SDK runs about 6–7× faster than the
|
|
|
926
1173
|
accelerated Python path, so adding a native addon — and with it the loss of
|
|
927
1174
|
bundler, edge-runtime and Deno compatibility — would buy nothing.
|
|
928
1175
|
|
|
1176
|
+
| Package | |
|
|
1177
|
+
|---|---:|
|
|
1178
|
+
| Tarball | 150 kB |
|
|
1179
|
+
| Unpacked | 629 kB |
|
|
1180
|
+
| Runtime dependencies | **0** |
|
|
1181
|
+
|
|
1182
|
+
Measured with `npm pack --dry-run` at 0.5.1. A stale duplicate of this section
|
|
1183
|
+
previously quoted 0.02 ms and 86 KB; both were wrong, and the figures above are
|
|
1184
|
+
the measured ones.
|
|
1185
|
+
|
|
929
1186
|
## CommonJS
|
|
930
1187
|
|
|
931
1188
|
```js
|
|
932
1189
|
const { redact } = require("euredact");
|
|
933
1190
|
```
|
|
934
1191
|
|
|
935
|
-
## Performance
|
|
936
|
-
|
|
937
|
-
| Metric | Value |
|
|
938
|
-
|---|---|
|
|
939
|
-
| Latency per redaction | ~0.02 ms |
|
|
940
|
-
| Package size | ~86 KB |
|
|
941
|
-
| Runtime dependencies | 0 |
|
|
942
|
-
|
|
943
1192
|
## License
|
|
944
1193
|
|
|
945
1194
|
Apache-2.0
|
package/dist/cjs/cloud/client.js
CHANGED
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* [CLOUD EXTENSION] The cloud client.
|
|
4
4
|
*
|
|
5
|
-
* Speaks to the euRedact inference service: a
|
|
6
|
-
*
|
|
7
|
-
* service returns both the redacted document and located spans, so this
|
|
5
|
+
* Speaks to the euRedact inference service: a fine-tuned model asked only
|
|
6
|
+
* *what did the rules miss?* The service returns located spans, so this
|
|
8
7
|
* module's job is transport and translation, not detection.
|
|
9
8
|
*
|
|
9
|
+
* It sends the text it is given, as given. `redactAsync(text, { mode: "cloud" })`
|
|
10
|
+
* is what runs the rules engine first and hands this client only the masked
|
|
11
|
+
* text; call the client directly and whatever you pass is what leaves the
|
|
12
|
+
* machine. The spans that come back index the text that was sent.
|
|
13
|
+
*
|
|
10
14
|
* Three things it hides from the caller:
|
|
11
15
|
*
|
|
12
16
|
* - **Whether the document finished inside the service's sync window.** A short
|
|
@@ -156,7 +160,6 @@ class CloudClient {
|
|
|
156
160
|
country: options.country.toUpperCase(),
|
|
157
161
|
language: options.language ?? "",
|
|
158
162
|
priority: options.priority ?? "interactive",
|
|
159
|
-
rules_only: options.rulesOnly ?? false,
|
|
160
163
|
});
|
|
161
164
|
let attempt = 0;
|
|
162
165
|
for (;;) {
|