euredact 0.4.0 → 0.5.1

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/dist/cjs/sdk.js CHANGED
@@ -33,13 +33,278 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.EuRedact = void 0;
36
+ exports.EuRedact = exports.TokenMapper = exports.TOKEN_SUFFIX_LENGTH = exports.TOKEN_ALPHABET = void 0;
37
+ exports.applyReplacements = applyReplacements;
38
+ exports.restore = restore;
39
+ const node_crypto_1 = require("node:crypto");
37
40
  const cache_js_1 = require("./cache.js");
38
41
  const normalizer_js_1 = require("./normalizer.js");
39
42
  const engine_js_1 = require("./rules/engine.js");
40
43
  const evidence_js_1 = require("./rules/evidence.js");
41
44
  const types_js_1 = require("./types.js");
42
45
  const DATE_TYPES = new Set([types_js_1.EntityType.DOB, types_js_1.EntityType.DATE_OF_DEATH]);
46
+ /**
47
+ * Splice a label over every detection and return the masked text.
48
+ *
49
+ * `detections` must be sorted by `(start, -end)`.
50
+ *
51
+ * Labels are resolved right-to-left because the referential mapper numbers
52
+ * each entity type in call order, and that order is part of the output
53
+ * contract. The string itself is then assembled in a single forward pass:
54
+ * rebuilding it per detection copied the whole document each time, which is
55
+ * O(document x detections) — 1.68 s of pure copying on a 1 MB document with
56
+ * 15,000 detections, versus 2 ms here.
57
+ *
58
+ * Spans from the rule engine are deduplicated and non-overlapping. Spans from
59
+ * elsewhere (the cloud service) are only sorted, so a span may start behind
60
+ * the cursor. Its uncovered tail is still masked rather than dropped: dropping
61
+ * the span would leave those characters in the clear, and splicing it whole
62
+ * would corrupt the label already emitted over its head.
63
+ */
64
+ function applyReplacements(text, detections, labelFor) {
65
+ const kept = [];
66
+ let pos = 0;
67
+ for (const det of detections) {
68
+ const start = Math.max(det.start, pos);
69
+ if (det.end <= start)
70
+ continue;
71
+ kept.push([det, start, det.end]);
72
+ pos = det.end;
73
+ }
74
+ const labels = new Array(kept.length);
75
+ for (let i = kept.length - 1; i >= 0; i--) {
76
+ const [det, start, end] = kept[i];
77
+ labels[i] = labelFor(det, text.slice(start, end));
78
+ }
79
+ const parts = [];
80
+ pos = 0;
81
+ for (let i = 0; i < kept.length; i++) {
82
+ const [, start, end] = kept[i];
83
+ parts.push(text.slice(pos, start), labels[i]);
84
+ pos = end;
85
+ }
86
+ parts.push(text.slice(pos));
87
+ return parts.join("");
88
+ }
89
+ /**
90
+ * Characters a token suffix is drawn from. No vowels, so a suffix never spells
91
+ * a word; no 0/1/I/O, so it survives being read back by a person; no
92
+ * underscore, so the type prefix stays unambiguous.
93
+ */
94
+ exports.TOKEN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
95
+ exports.TOKEN_SUFFIX_LENGTH = 4;
96
+ const TOKEN_MAX_DRAWS = 100;
97
+ function escapeRegExp(s) {
98
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
99
+ }
100
+ /**
101
+ * Maps each distinct PII value in one call to a reversible token.
102
+ *
103
+ * A token is `TYPE_XXXX`: the entity type, then `TOKEN_SUFFIX_LENGTH` random
104
+ * characters from `TOKEN_ALPHABET`. The same value gets the same token within
105
+ * the call, so relationships survive; across calls it gets a different one, so
106
+ * two tokenized documents never reveal a shared value.
107
+ *
108
+ * Nothing is kept beyond the call. The mapping goes to the caller in
109
+ * `RedactResult.tokens`, and only the caller can turn it back into text with
110
+ * `restore()`.
111
+ *
112
+ * Tokens are also kept clear of any token-shaped string already in the
113
+ * document. A caller that redacts an LLM's reply to a tokenized prompt has
114
+ * exactly such a document, and a fresh token colliding with an old one would
115
+ * make `restore()` put the wrong value back.
116
+ */
117
+ class TokenMapper {
118
+ byValue = new Map();
119
+ taken = new Set();
120
+ /** Token -> original value, for `RedactResult.tokens`. */
121
+ tokens = {};
122
+ constructor(text, detections) {
123
+ const types = [...new Set(detections.map(d => String(d.entityType)))].sort();
124
+ if (types.length === 0)
125
+ return;
126
+ const shaped = new RegExp(`(?:${types.map(escapeRegExp).join("|")})_[${exports.TOKEN_ALPHABET}]{${exports.TOKEN_SUFFIX_LENGTH}}`, "g");
127
+ for (const m of text.matchAll(shaped))
128
+ this.taken.add(m[0]);
129
+ }
130
+ /** Return the token for `value`, minting one on first sight. */
131
+ getToken = (det, value) => {
132
+ let token = this.byValue.get(value);
133
+ if (token === undefined) {
134
+ const prefix = `${det.entityType}_`;
135
+ let draws = 0;
136
+ do {
137
+ if (draws++ >= TOKEN_MAX_DRAWS) {
138
+ throw new Error(`could not mint a unique token with prefix ${JSON.stringify(prefix)} after ${TOKEN_MAX_DRAWS} draws`);
139
+ }
140
+ let suffix = "";
141
+ for (let i = 0; i < exports.TOKEN_SUFFIX_LENGTH; i++) {
142
+ suffix += exports.TOKEN_ALPHABET[(0, node_crypto_1.randomInt)(exports.TOKEN_ALPHABET.length)];
143
+ }
144
+ token = prefix + suffix;
145
+ } while (this.taken.has(token));
146
+ this.taken.add(token);
147
+ this.byValue.set(value, token);
148
+ this.tokens[token] = value;
149
+ }
150
+ return token;
151
+ };
152
+ }
153
+ exports.TokenMapper = TokenMapper;
154
+ /**
155
+ * Put the original values back into `text`.
156
+ *
157
+ * `tokens` is `RedactResult.tokens` from the `redact(text, { tokenize: true })`
158
+ * call that produced the text this one derives from — typically the reply an
159
+ * LLM wrote to the tokenized prompt. Every occurrence of every token is
160
+ * replaced; a token an LLM glued to other characters (`EMAIL_K7Q2s`) is still
161
+ * restored, since leaving a token behind is the worse failure.
162
+ */
163
+ function restore(text, tokens) {
164
+ const keys = Object.keys(tokens);
165
+ if (keys.length === 0)
166
+ return text;
167
+ // Longest first so a token that is a prefix of another (custom pattern names
168
+ // allow it) cannot be matched short. The replacement is a function so `$&`
169
+ // and friends in the original values are literal.
170
+ keys.sort((a, b) => b.length - a.length);
171
+ const pattern = new RegExp(keys.map(escapeRegExp).join("|"), "g");
172
+ return text.replace(pattern, m => tokens[m]);
173
+ }
174
+ /**
175
+ * Types whose separators are presentational: the same identifier stays the
176
+ * same value however it is spaced, hyphenated or dotted, so an allowlisted
177
+ * IBAN is exempted whether the document writes NL91ABNA0417164300 or
178
+ * NL91 ABNA 0417 1643 00 (issue rules-engine#15).
179
+ *
180
+ * Free-text types are deliberately absent: folding separators in an address
181
+ * would make `jan.devries@acme.be` exempt `jandevries@acme.be`, a different
182
+ * mailbox at most providers.
183
+ */
184
+ const SEPARATOR_INSENSITIVE = new Set([
185
+ types_js_1.EntityType.BANK_ACCOUNT, types_js_1.EntityType.BIC, types_js_1.EntityType.CREDIT_CARD,
186
+ types_js_1.EntityType.PHONE, types_js_1.EntityType.VAT, types_js_1.EntityType.NATIONAL_ID, types_js_1.EntityType.SSN,
187
+ types_js_1.EntityType.TAX_ID, types_js_1.EntityType.PASSPORT, types_js_1.EntityType.DRIVERS_LICENSE,
188
+ types_js_1.EntityType.RESIDENCE_PERMIT, types_js_1.EntityType.HEALTH_INSURANCE,
189
+ types_js_1.EntityType.CHAMBER_OF_COMMERCE, types_js_1.EntityType.IMEI, types_js_1.EntityType.VIN,
190
+ ]);
191
+ /** Types carrying a domain an owner may want exempted wholesale. */
192
+ const DOMAIN_BEARING = new Set([types_js_1.EntityType.EMAIL, types_js_1.EntityType.URL]);
193
+ const SEPARATORS = /[\s.\-/()]+/g;
194
+ /**
195
+ * Reject a bare string where a list of allowlisted values is expected.
196
+ *
197
+ * A string is iterable, so `allowlist: "ACME NV"` would become the
198
+ * one-character entries "A", "C", ... — none of which is a whole detection, so
199
+ * nothing is exempted and the caller's own name is redacted after all.
200
+ */
201
+ function checkAllowlistArg(value, param = "allowlist") {
202
+ if (typeof value === "string") {
203
+ throw new TypeError(`${param} must be an array of values, not a bare string. ` +
204
+ `Pass ${param}: ["${value}"] rather than ${param}: "${value}".`);
205
+ }
206
+ }
207
+ /**
208
+ * The form an allowlist entry and a detected value are compared in. NFC
209
+ * because the rule engine matches on NFC-normalised text while the document
210
+ * may be NFD; lower-cased because an org name in a heading is the same org.
211
+ */
212
+ function allowlistKey(value) {
213
+ return value.normalize("NFC").trim().toLowerCase();
214
+ }
215
+ /** `allowlistKey` with presentational separators removed. */
216
+ function foldedKey(value) {
217
+ return allowlistKey(value).replace(SEPARATORS, "");
218
+ }
219
+ /** Comparison key -> the entry as the caller wrote it (for reporting). */
220
+ function normalizeAllowlist(values) {
221
+ checkAllowlistArg(values);
222
+ const out = new Map();
223
+ for (const v of values ?? []) {
224
+ if (!v || !v.trim())
225
+ continue;
226
+ if (!out.has(allowlistKey(v)))
227
+ out.set(allowlistKey(v), v);
228
+ if (!out.has(foldedKey(v)))
229
+ out.set(foldedKey(v), v);
230
+ }
231
+ return out;
232
+ }
233
+ /** Domain key -> the entry as written. A leading `@` or `.` is ignored. */
234
+ function normalizeDomains(values) {
235
+ checkAllowlistArg(values, "allowlistDomains");
236
+ const out = new Map();
237
+ for (const v of values ?? []) {
238
+ if (!v || !v.trim())
239
+ continue;
240
+ const k = allowlistKey(v).replace(/^[@.]+/, "");
241
+ if (k && !out.has(k))
242
+ out.set(k, v);
243
+ }
244
+ return out;
245
+ }
246
+ /** The domain an exemption rule would apply to, or null. */
247
+ function domainOf(entityType, value) {
248
+ if (!DOMAIN_BEARING.has(entityType))
249
+ return null;
250
+ let v = allowlistKey(value);
251
+ if (v.includes("@"))
252
+ return v.slice(v.lastIndexOf("@") + 1).replace(/^[<[(]+|[>\])(),;:"']+$/g, "") || null;
253
+ v = v.replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
254
+ return v.split("/")[0].split(":")[0].replace(/^[<[(]+|[>\])(),;:"']+$/g, "") || null;
255
+ }
256
+ /** The [rule, kind] exempting this detection, or null. */
257
+ function matchingRule(det, slice, allowed, domains) {
258
+ for (const candidate of [slice, det.text]) {
259
+ if (!candidate)
260
+ continue;
261
+ const exact = allowed.get(allowlistKey(candidate));
262
+ if (exact !== undefined)
263
+ return [exact, "value"];
264
+ if (SEPARATOR_INSENSITIVE.has(det.entityType)) {
265
+ const folded = allowed.get(foldedKey(candidate));
266
+ if (folded !== undefined)
267
+ return [folded, "value"];
268
+ }
269
+ const host = domainOf(det.entityType, candidate);
270
+ if (host) {
271
+ for (const [owned, entry] of domains) {
272
+ if (host === owned || host.endsWith("." + owned))
273
+ return [entry, "domain"];
274
+ }
275
+ }
276
+ }
277
+ return null;
278
+ }
279
+ /**
280
+ * Split detections into those to redact and those the caller exempted.
281
+ *
282
+ * Whole span only: `euredact.be` as a *value* does not exempt
283
+ * `joren@euredact.be`; that is what `allowlistDomains` is for.
284
+ */
285
+ function applyAllowlist(text, detections, allowed, domains) {
286
+ if (allowed.size === 0 && domains.size === 0)
287
+ return [detections, []];
288
+ const kept = [];
289
+ const exempted = [];
290
+ for (const d of detections) {
291
+ const hit = matchingRule(d, text.slice(d.start, d.end), allowed, domains);
292
+ if (hit === null)
293
+ kept.push(d);
294
+ else
295
+ exempted.push({
296
+ entityType: d.entityType, start: d.start, end: d.end,
297
+ text: text.slice(d.start, d.end), rule: hit[0], ruleKind: hit[1],
298
+ });
299
+ }
300
+ return [kept, exempted];
301
+ }
302
+ /** Two label schemes for the same spans cannot both apply. */
303
+ function checkLabelOptions(options) {
304
+ if (options.tokenize && options.referentialIntegrity) {
305
+ throw new Error("tokenize and referentialIntegrity are two label schemes for the same spans; pass one of them");
306
+ }
307
+ }
43
308
  /** Entry count at which a one-time warning is emitted. */
44
309
  const MAPPING_WARN_THRESHOLD = 100_000;
45
310
  /**
@@ -81,40 +346,6 @@ class ReferentialMapper {
81
346
  }
82
347
  }
83
348
  const DEFAULT_MAX_INPUT_LENGTH = 10_485_760; // ~10 MB of text
84
- /**
85
- * Send a document to the cloud tier.
86
- *
87
- * Options the service cannot honour throw rather than being ignored. Silently
88
- * dropping one would mean returning a result that does not match what was
89
- * asked for — which, for anything that changes which spans come back, is
90
- * under-redaction wearing a plausible face.
91
- */
92
- async function redactViaCloud(text, options) {
93
- const { CloudClient } = await Promise.resolve().then(() => __importStar(require("./cloud/client.js")));
94
- const countries = options.countries ?? null;
95
- if (!countries || countries.length !== 1) {
96
- throw new Error('cloud mode needs exactly one country, e.g. { countries: ["BE"] }. The ' +
97
- "model is trained and evaluated per country, so a multi-country request " +
98
- "has no defined behaviour.");
99
- }
100
- if (options.countryHint) {
101
- throw new Error("countryHint is not supported in cloud mode");
102
- }
103
- if (options.context || options.chunkOffset) {
104
- throw new Error("context/chunkOffset are not supported in cloud mode: the model has never " +
105
- "seen a chunk boundary, so the service rejects oversized input rather " +
106
- "than splitting it");
107
- }
108
- if (options.referentialIntegrity) {
109
- throw new Error("referentialIntegrity is not supported in cloud mode");
110
- }
111
- // detectDates is deliberately NOT forwarded. The service always runs its
112
- // rules engine with dates on, because that is what the model was trained
113
- // against; the caller's value cannot change that. It is the one ignored
114
- // option that is safe to ignore — it can only cause MORE to be detected,
115
- // never less, so it cannot produce under-redaction.
116
- return new CloudClient().redact(text, { country: countries[0] });
117
- }
118
349
  /**
119
350
  * Reject a bare string where a list of country codes is expected.
120
351
  *
@@ -146,8 +377,18 @@ class EuRedact {
146
377
  cache = new cache_js_1.ResultCache();
147
378
  referentialMapper = new ReferentialMapper();
148
379
  maxInputLength;
380
+ allowlist;
381
+ allowlistDomains;
382
+ /**
383
+ * @param options.maxInputLength Longest document `redact` accepts, in characters.
384
+ * @param options.allowlist Values never to redact, for every call on this
385
+ * instance — an organisation's own name, its own addresses. Merged with
386
+ * the per-call `allowlist`. Matched whole, case-insensitively.
387
+ */
149
388
  constructor(options) {
150
389
  this.maxInputLength = options?.maxInputLength ?? DEFAULT_MAX_INPUT_LENGTH;
390
+ this.allowlist = normalizeAllowlist(options?.allowlist);
391
+ this.allowlistDomains = normalizeDomains(options?.allowlistDomains);
151
392
  }
152
393
  addCustomPattern(name, pattern) {
153
394
  this.engine.addCustomPattern(name, pattern);
@@ -159,9 +400,101 @@ class EuRedact {
159
400
  this.cache.clear();
160
401
  this.referentialMapper.clear();
161
402
  }
403
+ /** The instance allowlist merged with a call's, in comparison form. */
404
+ allowedFor(options) {
405
+ const allowed = new Map(this.allowlist);
406
+ for (const [k, v] of normalizeAllowlist(options.allowlist))
407
+ allowed.set(k, v);
408
+ return allowed;
409
+ }
410
+ /** The instance domain list merged with a call's. */
411
+ domainsFor(options) {
412
+ const domains = new Map(this.allowlistDomains);
413
+ for (const [k, v] of normalizeDomains(options.allowlistDomains))
414
+ domains.set(k, v);
415
+ return domains;
416
+ }
417
+ /** The label function for one call, given its output options. */
418
+ labelFor(referentialIntegrity, tokenMapper) {
419
+ if (tokenMapper !== null)
420
+ return tokenMapper.getToken;
421
+ if (referentialIntegrity) {
422
+ const mapper = this.referentialMapper;
423
+ return (det) => mapper.getLabel(det.text, det.entityType);
424
+ }
425
+ return (det) => `[${det.entityType}]`;
426
+ }
427
+ /**
428
+ * Send a document to the cloud tier.
429
+ *
430
+ * Options the service cannot honour throw rather than being ignored. Silently
431
+ * dropping one would mean returning a result that does not match what was
432
+ * asked for — which, for anything that changes which spans come back, is
433
+ * under-redaction wearing a plausible face.
434
+ */
435
+ async redactViaCloud(text, options) {
436
+ const { CloudClient } = await Promise.resolve().then(() => __importStar(require("./cloud/client.js")));
437
+ const countries = options.countries ?? null;
438
+ if (!countries || countries.length !== 1) {
439
+ throw new Error('cloud mode needs exactly one country, e.g. { countries: ["BE"] }. The ' +
440
+ "model is trained and evaluated per country, so a multi-country request " +
441
+ "has no defined behaviour.");
442
+ }
443
+ if (options.countryHint) {
444
+ throw new Error("countryHint is not supported in cloud mode");
445
+ }
446
+ if (options.context || options.chunkOffset) {
447
+ throw new Error("context/chunkOffset are not supported in cloud mode: the model has never " +
448
+ "seen a chunk boundary, so the service rejects oversized input rather " +
449
+ "than splitting it");
450
+ }
451
+ if (options.referentialIntegrity) {
452
+ throw new Error("referentialIntegrity is not supported in cloud mode");
453
+ }
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
+ const allowed = this.allowedFor(options);
460
+ const domains = this.domainsFor(options);
461
+ const result = await new CloudClient().redact(text, { country: countries[0] });
462
+ if (options.tokenize || allowed.size > 0 || domains.size > 0) {
463
+ await this.remaskCloudResult(result, text, { tokenize: options.tokenize ?? false, allowed, domains });
464
+ }
465
+ return result;
466
+ }
467
+ /**
468
+ * Rebuild the masked text from the service's spans, in place.
469
+ *
470
+ * The service masks with bracketed labels only. Any other output option is
471
+ * applied here, from its spans: the service builds its `redacted_text` from
472
+ * exactly the spans it returns (entities it reports but cannot place are
473
+ * listed separately and never applied), so nothing it masked is lost by
474
+ * masking again from the same spans.
475
+ *
476
+ * The service reports offsets in code points and this SDK slices UTF-16
477
+ * units, so the slice check is what stands between an emoji in the document
478
+ * and a rebuilt text with the wrong characters masked.
479
+ */
480
+ async remaskCloudResult(result, text, opts) {
481
+ const { CloudError } = await Promise.resolve().then(() => __importStar(require("./cloud/errors.js")));
482
+ for (const det of result.detections) {
483
+ if (text.slice(det.start, det.end) !== det.text) {
484
+ throw new CloudError("span offsets do not match the document; cannot apply tokenize/allowlist locally");
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 : {};
491
+ }
162
492
  redact(text, options = {}) {
163
493
  checkCountryArg(options.countries, "countries");
164
494
  checkCountryArg(options.countryHint, "countryHint");
495
+ checkLabelOptions(options);
496
+ const allowed = this.allowedFor(options);
497
+ const domains = this.domainsFor(options);
165
498
  const requestedMode = options.mode ?? "rules";
166
499
  if (requestedMode === "cloud") {
167
500
  // Cannot be served synchronously: the cloud tier is a network call, and
@@ -180,7 +513,7 @@ class EuRedact {
180
513
  `(${this.maxInputLength.toLocaleString()} chars). Split the input or ` +
181
514
  `increase maxInputLength when constructing EuRedact.`);
182
515
  }
183
- const { countries = null, countryHint = null, context = null, chunkOffset = 0, mode = "rules", referentialIntegrity = false, detectDates = false, } = options;
516
+ const { countries = null, countryHint = null, context = null, chunkOffset = 0, mode = "rules", referentialIntegrity = false, tokenize = false, detectDates = false, } = options;
184
517
  // A context makes the result depend on evidence from other chunks, so the
185
518
  // text no longer identifies the result. Caching is disabled rather than
186
519
  // keyed on the context, whose contents change as chunks arrive.
@@ -191,7 +524,14 @@ class EuRedact {
191
524
  : ["ALL"];
192
525
  // countryHint changes attribution, so it must key the cache too.
193
526
  const hintKey = countryHint ? countryHint.map(c => c.toUpperCase()).sort().join(",") : "";
194
- const cacheMode = `${mode}|dates=${detectDates}|hint=${hintKey}`;
527
+ // referentialIntegrity changes the labels, not the spans, so a cached
528
+ // bracketed result is the wrong answer for a labelled call on the same
529
+ // text — it has to key the cache too.
530
+ // JSON rather than a joined string: an entry may itself contain the
531
+ // separator, and two different lists must never share a key.
532
+ const allowKey = (allowed.size ? JSON.stringify([...allowed.keys()].sort()) : "")
533
+ + (domains.size ? "|dom=" + JSON.stringify([...domains.keys()].sort()) : "");
534
+ const cacheMode = `${mode}|dates=${detectDates}|hint=${hintKey}|ri=${referentialIntegrity}|tok=${tokenize}|allow=${allowKey}`;
195
535
  let cacheKey;
196
536
  if (cache) {
197
537
  cacheKey = this.cache.key(normalizedText, countriesTuple, cacheMode);
@@ -213,33 +553,11 @@ class EuRedact {
213
553
  if (!detectDates) {
214
554
  detections = detections.filter(d => !DATE_TYPES.has(d.entityType));
215
555
  }
556
+ let exempted;
557
+ [detections, exempted] = applyAllowlist(text, detections, allowed, domains);
216
558
  detections.sort((a, b) => a.start - b.start || b.end - a.end);
217
- // Labels are resolved right-to-left because the referential mapper numbers
218
- // each entity type in call order, and that order is part of the output
219
- // contract. The string itself is then assembled in a single forward pass:
220
- // rebuilding it per detection copied the whole document each time, which is
221
- // O(document x detections) — 1.68 s of pure copying on a 1 MB document with
222
- // 15,000 detections, versus 2 ms here.
223
- const replacements = new Array(detections.length);
224
- for (let i = detections.length - 1; i >= 0; i--) {
225
- const det = detections[i];
226
- replacements[i] = referentialIntegrity
227
- ? this.referentialMapper.getLabel(det.text, det.entityType)
228
- : `[${det.entityType}]`;
229
- }
230
- const parts = [];
231
- let pos = 0;
232
- for (let i = 0; i < detections.length; i++) {
233
- const det = detections[i];
234
- // Spans reach here deduplicated and non-overlapping; one starting behind
235
- // the cursor would splice into the middle of a label, so drop it.
236
- if (det.start < pos)
237
- continue;
238
- parts.push(text.slice(pos, det.start), replacements[i]);
239
- pos = det.end;
240
- }
241
- parts.push(text.slice(pos));
242
- const redacted = parts.join("");
559
+ const tokenMapper = tokenize ? new TokenMapper(text, detections) : null;
560
+ const redacted = applyReplacements(text, detections, this.labelFor(referentialIntegrity, tokenMapper));
243
561
  // Report the inference so it can be audited. Spans in `evidence` are
244
562
  // offsets into the normalised text, matching `detections`.
245
563
  const inferredCountries = [...(0, evidence_js_1.weightsToRanking)(scores).entries()]
@@ -252,6 +570,8 @@ class EuRedact {
252
570
  inferredCountries,
253
571
  evidence,
254
572
  detectionMode: countries && countries.length ? "declared" : "inferred",
573
+ tokens: tokenMapper ? tokenMapper.tokens : {},
574
+ exempted,
255
575
  };
256
576
  if (cache && cacheKey) {
257
577
  this.cache.put(cacheKey, result);
@@ -268,7 +588,8 @@ class EuRedact {
268
588
  const mode = options.mode ?? "rules";
269
589
  if (mode !== "cloud")
270
590
  return this.redact(text, options);
271
- return redactViaCloud(text, options);
591
+ checkLabelOptions(options);
592
+ return this.redactViaCloud(text, options);
272
593
  }
273
594
  redactBatch(texts, options = {}) {
274
595
  // Generation is country-blind, so every country's patterns are loaded
@@ -1,4 +1,6 @@
1
1
  import type { RedactResult } from "./types.js";
2
+ /** Approximate retained size of a cached result, in characters. */
3
+ export declare function resultChars(result: RedactResult): number;
2
4
  export declare class ResultCache {
3
5
  private maxsize;
4
6
  private maxChars;
package/dist/esm/cache.js CHANGED
@@ -4,10 +4,14 @@ import { createHash } from "node:crypto";
4
4
  // gigabyte of PII-bearing text held live. ~16M characters is a few tens of MB.
5
5
  const DEFAULT_MAX_CHARS = 16_000_000;
6
6
  /** Approximate retained size of a cached result, in characters. */
7
- function resultChars(result) {
7
+ export function resultChars(result) {
8
8
  let chars = result.redactedText.length;
9
9
  for (const d of result.detections)
10
10
  chars += d.text.length;
11
+ for (const [token, value] of Object.entries(result.tokens))
12
+ chars += token.length + value.length;
13
+ for (const e of result.exempted)
14
+ chars += e.text.length + e.rule.length;
11
15
  return chars;
12
16
  }
13
17
  export class ResultCache {
@@ -71,11 +71,33 @@ function toEntityType(raw) {
71
71
  const known = Object.values(EntityType).includes(canonical);
72
72
  return known ? canonical : canonical;
73
73
  }
74
+ /**
75
+ * Map the service's offsets, which count code points, onto this SDK's, which
76
+ * count UTF-16 units.
77
+ *
78
+ * The two agree until the first character outside the BMP — an emoji, say —
79
+ * after which every service offset is short by one unit per such character.
80
+ * Returns the identity when the text has none, which is the common case and
81
+ * costs one regex test.
82
+ */
83
+ function codePointOffsets(text) {
84
+ if (!/[\uD800-\uDBFF]/.test(text))
85
+ return cp => cp;
86
+ const units = [];
87
+ let i = 0;
88
+ for (const ch of text) {
89
+ units.push(i);
90
+ i += ch.length;
91
+ }
92
+ units.push(i);
93
+ return cp => units[Math.min(cp, units.length - 1)];
94
+ }
74
95
  function toResult(payload, text) {
96
+ const offset = codePointOffsets(text);
75
97
  const detections = (payload.entities ?? []).map(span => ({
76
98
  entityType: toEntityType(span.type),
77
- start: span.start,
78
- end: span.end,
99
+ start: offset(span.start),
100
+ end: offset(span.end),
79
101
  text: span.text,
80
102
  source: span.source === "model" ? DetectionSource.CLOUD : DetectionSource.RULES,
81
103
  country: null,
@@ -90,6 +112,8 @@ function toResult(payload, text) {
90
112
  inferredCountries: [],
91
113
  evidence: [],
92
114
  detectionMode: "declared",
115
+ tokens: {},
116
+ exempted: [],
93
117
  };
94
118
  }
95
119
  export class CloudClient {
@@ -1,7 +1,7 @@
1
1
  export { EntityType, DetectionSource, LEGACY_TYPE_ALIASES, canonicalType } from "./types.js";
2
- export type { Detection, RedactResult, PatternDef, CountryConfig, CountryEvidence } from "./types.js";
2
+ export type { Detection, Exemption, RedactResult, PatternDef, CountryConfig, CountryEvidence } from "./types.js";
3
3
  export { DocumentContext } from "./rules/context.js";
4
- export { EuRedact, type RedactOptions } from "./sdk.js";
4
+ export { EuRedact, restore, type RedactOptions } from "./sdk.js";
5
5
  export { COUNTRY_CONFIGS } from "./rules/countries/index.js";
6
6
  export { setBicRegistry, getBicRegistry, SEED_BIC6_PREFIXES, type BicRegistryProvider } from "./rules/bicRegistry.js";
7
7
  export { configure, getConfig, CloudClient, CloudError, NotConfiguredError, QuotaExceededError, TooLargeError, type CloudConfig, type ConfigureOptions, type CloudRedactOptions, } from "./cloud/index.js";
package/dist/esm/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { EntityType, DetectionSource, LEGACY_TYPE_ALIASES, canonicalType } from "./types.js";
2
2
  export { DocumentContext } from "./rules/context.js";
3
- export { EuRedact } from "./sdk.js";
3
+ export { EuRedact, restore } from "./sdk.js";
4
4
  export { COUNTRY_CONFIGS } from "./rules/countries/index.js";
5
5
  export { setBicRegistry, getBicRegistry, SEED_BIC6_PREFIXES } from "./rules/bicRegistry.js";
6
6
  export { configure, getConfig, CloudClient, CloudError, NotConfiguredError, QuotaExceededError, TooLargeError, } from "./cloud/index.js";