euredact 0.7.0 → 0.7.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/esm/sdk.js CHANGED
@@ -3,7 +3,7 @@ import { ResultCache } from "./cache.js";
3
3
  import { normalize, mapOffsetToOriginal } from "./normalizer.js";
4
4
  import { RuleEngine } from "./rules/engine.js";
5
5
  import { weightsToRanking } from "./rules/evidence.js";
6
- import { EntityType } from "./types.js";
6
+ import { EntityType, canonicalType } from "./types.js";
7
7
  const DATE_TYPES = new Set([EntityType.DOB, EntityType.DATE_OF_DEATH]);
8
8
  /**
9
9
  * Splice a label over every detection and return the masked text.
@@ -353,6 +353,70 @@ function applyAllowlist(text, detections, allowed, domains) {
353
353
  }
354
354
  return [kept, exempted];
355
355
  }
356
+ const KNOWN_TYPES = new Set(Object.values(EntityType));
357
+ /**
358
+ * The entity types a caller asked to leave in the text, by canonical name.
359
+ *
360
+ * Names are matched case-insensitively and legacy names resolve (`IBAN` is
361
+ * `BANK_ACCOUNT`, `CREDENTIAL` is `SECRET`). A custom pattern's name counts
362
+ * once it is registered. Anything else throws: a typo in a type meant to be
363
+ * switched off would otherwise switch nothing off (rules-engine#125).
364
+ */
365
+ export function normalizeExcludeTypes(values, customNames = new Set(), param = "excludeTypes") {
366
+ const out = new Set();
367
+ if (values === null || values === undefined)
368
+ return out;
369
+ checkAllowlistArg(values, param);
370
+ for (const v of values) {
371
+ if (typeof v !== "string") {
372
+ throw new TypeError(`${param} entries must be type names, not ${typeof v}`);
373
+ }
374
+ if (customNames.has(v)) {
375
+ out.add(v);
376
+ continue;
377
+ }
378
+ const name = canonicalType(v);
379
+ if (!KNOWN_TYPES.has(name)) {
380
+ throw new Error(`unknown entity type ${JSON.stringify(v)} in ${param}. Known types: ` +
381
+ [...KNOWN_TYPES].sort().join(", ") +
382
+ (customNames.size ? `; custom patterns: ${[...customNames].sort().join(", ")}` : ""));
383
+ }
384
+ out.add(name);
385
+ }
386
+ return out;
387
+ }
388
+ /**
389
+ * Split off the detections of the types the caller excluded. Applied to each
390
+ * span's final type, after ranking: dropping an excluded type's candidates
391
+ * earlier would let the next-best type claim the same span.
392
+ */
393
+ function applyTypeExclusion(text, detections, excluded) {
394
+ if (excluded.size === 0)
395
+ return [detections, []];
396
+ const kept = [];
397
+ const exempted = [];
398
+ for (const d of detections) {
399
+ const name = String(d.entityType);
400
+ if (excluded.has(name)) {
401
+ exempted.push({
402
+ entityType: d.entityType, start: d.start, end: d.end,
403
+ text: text.slice(d.start, d.end), rule: name, ruleKind: "type",
404
+ });
405
+ }
406
+ else
407
+ kept.push(d);
408
+ }
409
+ return [kept, exempted];
410
+ }
411
+ /** Allowlist first, then excluded types; every exemption in text order. */
412
+ function exempt(text, detections, allowed, domains, excluded) {
413
+ const [afterValues, byValue] = applyAllowlist(text, detections, allowed, domains);
414
+ const [kept, byType] = applyTypeExclusion(text, afterValues, excluded);
415
+ const exempted = [...byValue, ...byType];
416
+ if (byValue.length && byType.length)
417
+ exempted.sort((a, b) => a.start - b.start || b.end - a.end);
418
+ return [kept, exempted];
419
+ }
356
420
  /** Two label schemes for the same spans cannot both apply. */
357
421
  function checkLabelOptions(options) {
358
422
  if (options.tokenize && options.referentialIntegrity) {
@@ -433,19 +497,26 @@ export class EuRedact {
433
497
  maxInputLength;
434
498
  allowlist;
435
499
  allowlistDomains;
500
+ customNames = new Set();
501
+ excludeTypes;
436
502
  /**
437
503
  * @param options.maxInputLength Longest document `redact` accepts, in characters.
438
504
  * @param options.allowlist Values never to redact, for every call on this
439
505
  * instance — an organisation's own name, its own addresses. Merged with
440
506
  * the per-call `allowlist`. Matched whole, case-insensitively.
507
+ * @param options.excludeTypes Entity types never reported or masked, for
508
+ * every call on this instance, e.g. `["JOB_TITLE", "FINANCIAL_AMOUNT"]`.
509
+ * Merged with the per-call value. Unknown names throw.
441
510
  */
442
511
  constructor(options) {
443
512
  this.maxInputLength = options?.maxInputLength ?? DEFAULT_MAX_INPUT_LENGTH;
444
513
  this.allowlist = normalizeAllowlist(options?.allowlist);
445
514
  this.allowlistDomains = normalizeDomains(options?.allowlistDomains);
515
+ this.excludeTypes = normalizeExcludeTypes(options?.excludeTypes);
446
516
  }
447
517
  addCustomPattern(name, pattern) {
448
518
  this.engine.addCustomPattern(name, pattern);
519
+ this.customNames.add(name);
449
520
  this.cache.clear();
450
521
  }
451
522
  /** Clear the result cache and referential integrity mappings.
@@ -461,6 +532,13 @@ export class EuRedact {
461
532
  allowed.set(k, v);
462
533
  return allowed;
463
534
  }
535
+ /** The instance's excluded types merged with a call's. */
536
+ excludedFor(options) {
537
+ const excluded = new Set(this.excludeTypes);
538
+ for (const t of normalizeExcludeTypes(options.excludeTypes, this.customNames))
539
+ excluded.add(t);
540
+ return excluded;
541
+ }
464
542
  /** The instance domain list merged with a call's. */
465
543
  domainsFor(options) {
466
544
  const domains = new Map(this.allowlistDomains);
@@ -517,14 +595,16 @@ export class EuRedact {
517
595
  }
518
596
  const allowed = this.allowedFor(options);
519
597
  const domains = this.domainsFor(options);
598
+ const excluded = this.excludedFor(options);
520
599
  const client = new CloudClient();
521
600
  // Dates are always on, whatever detectDates says: that is the rules output
522
601
  // the model was trained against, and a date of birth the rules can place
523
602
  // has no reason to travel. It is the one ignored option that is safe to
524
603
  // ignore — it can only cause MORE to be detected, never less.
525
604
  //
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
605
+ // No allowlist, no excluded types and no tokens on this pass. An exemption
606
+ // says what the caller wants back, not what may leave: an excluded EMAIL is
607
+ // still masked in the request. All three are applied below, after the
528
608
  // response, to the merged spans.
529
609
  const local = this.redactRules(text, { countries, detectDates: true, cache: options.cache }, new Map(), new Map());
530
610
  const [masked, labels] = maskForCloud(text, local.detections);
@@ -534,7 +614,7 @@ export class EuRedact {
534
614
  country: countries[0], idempotencyKey: options.idempotencyKey,
535
615
  });
536
616
  // 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);
617
+ const [detections, exempted] = exempt(text, [...local.detections, ...ontoOriginal(remote.detections, masked, text, labels, CloudError)], allowed, domains, excluded);
538
618
  detections.sort((a, b) => a.start - b.start || b.end - a.end);
539
619
  const tokenMapper = options.tokenize ? new TokenMapper(text, detections) : null;
540
620
  return {
@@ -553,6 +633,7 @@ export class EuRedact {
553
633
  checkLabelOptions(options);
554
634
  const allowed = this.allowedFor(options);
555
635
  const domains = this.domainsFor(options);
636
+ const excluded = this.excludedFor(options);
556
637
  const requestedMode = options.mode ?? "rules";
557
638
  if (requestedMode === "cloud") {
558
639
  // Cannot be served synchronously: the cloud tier is a network call, and
@@ -569,10 +650,10 @@ export class EuRedact {
569
650
  if (options.idempotencyKey !== undefined) {
570
651
  throw new Error('idempotencyKey applies to mode: "cloud" only: a rules-only redaction makes no request to deduplicate');
571
652
  }
572
- return this.redactRules(text, options, allowed, domains);
653
+ return this.redactRules(text, options, allowed, domains, excluded);
573
654
  }
574
655
  /** The local pipeline: normalise, detect, exempt, mask. */
575
- redactRules(text, options, allowed, domains) {
656
+ redactRules(text, options, allowed, domains, excluded = new Set()) {
576
657
  if (text.length > this.maxInputLength) {
577
658
  throw new Error(`Input text length (${text.length.toLocaleString()} chars) exceeds the maximum ` +
578
659
  `(${this.maxInputLength.toLocaleString()} chars). Split the input or ` +
@@ -595,7 +676,8 @@ export class EuRedact {
595
676
  // JSON rather than a joined string: an entry may itself contain the
596
677
  // separator, and two different lists must never share a key.
597
678
  const allowKey = (allowed.size ? JSON.stringify([...allowed.keys()].sort()) : "")
598
- + (domains.size ? "|dom=" + JSON.stringify([...domains.keys()].sort()) : "");
679
+ + (domains.size ? "|dom=" + JSON.stringify([...domains.keys()].sort()) : "")
680
+ + (excluded.size ? "|excl=" + JSON.stringify([...excluded].sort()) : "");
599
681
  const cacheMode = `rules|dates=${detectDates}|hint=${hintKey}|ri=${referentialIntegrity}|tok=${tokenize}|allow=${allowKey}`;
600
682
  let cacheKey;
601
683
  if (cache) {
@@ -619,7 +701,7 @@ export class EuRedact {
619
701
  detections = detections.filter(d => !DATE_TYPES.has(d.entityType));
620
702
  }
621
703
  let exempted;
622
- [detections, exempted] = applyAllowlist(text, detections, allowed, domains);
704
+ [detections, exempted] = exempt(text, detections, allowed, domains, excluded);
623
705
  detections.sort((a, b) => a.start - b.start || b.end - a.end);
624
706
  const tokenMapper = tokenize ? new TokenMapper(text, detections) : null;
625
707
  const redacted = applyReplacements(text, detections, this.labelFor(referentialIntegrity, tokenMapper));
@@ -141,7 +141,7 @@ export interface Detection {
141
141
  outOfScope?: boolean;
142
142
  }
143
143
  /**
144
- * A detection the allowlist kept out of the redacted output.
144
+ * A detection the allowlist or `excludeTypes` kept out of the redacted output.
145
145
  *
146
146
  * An exemption is a deliberate decision to leave a direct identifier in a
147
147
  * document that otherwise claims to be redacted, so it is reported rather than
@@ -153,10 +153,12 @@ export interface Exemption {
153
153
  start: number;
154
154
  end: number;
155
155
  text: string;
156
- /** The allowlist entry that matched, as the caller wrote it. */
156
+ /** The allowlist entry that matched, as the caller wrote it; for an
157
+ * excluded type, the type's canonical name. */
157
158
  rule: string;
158
- /** `"value"` for the literal allowlist, `"domain"` for allowlistDomains. */
159
- ruleKind: "value" | "domain";
159
+ /** `"value"` for the literal allowlist, `"domain"` for allowlistDomains,
160
+ * `"type"` for excludeTypes (rules-engine#125). */
161
+ ruleKind: "value" | "domain" | "type";
160
162
  }
161
163
  /**
162
164
  * One reason a cloud request cost what it did. `code` is one of the service's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euredact",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "European PII redaction SDK -- fast, zero-dependency, rule-based detection and redaction of personal data across 31 European countries",
5
5
  "homepage": "https://euredact.dev",
6
6
  "license": "Apache-2.0",
@@ -59,7 +59,7 @@
59
59
  "build:cjs": "tsc -p tsconfig.cjs.json",
60
60
  "postbuild:cjs": "echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json",
61
61
  "build": "rm -rf dist && npm run build:esm && npm run build:cjs",
62
- "test": "npm run test:spec && npm run test:inference && npm run test:cloud && npm run test:cloud-api && npm run test:tokenize && npm run test:allowlist && npm run test:eval-scoring && npm run test:platform && npm run test:batches && npm run test:conformance",
62
+ "test": "npm run test:spec && npm run test:inference && npm run test:cloud && npm run test:cloud-api && npm run test:tokenize && npm run test:allowlist && npm run test:exclude-types && npm run test:eval-scoring && npm run test:platform && npm run test:batches && npm run test:conformance",
63
63
  "benchmark": "node --import tsx src/__tests__/benchmark.ts",
64
64
  "prepublishOnly": "npm run build",
65
65
  "test:spec": "node --import tsx src/__tests__/spec.ts",
@@ -72,6 +72,7 @@
72
72
  "test:cloud-api": "node --import tsx src/__tests__/cloudApi.ts",
73
73
  "test:tokenize": "node --import tsx src/__tests__/tokenize.ts",
74
74
  "test:allowlist": "node --import tsx src/__tests__/allowlist.ts",
75
+ "test:exclude-types": "node --import tsx src/__tests__/excludeTypes.ts",
75
76
  "test:eval-scoring": "node --import tsx src/__tests__/evalScoring.ts",
76
77
  "test:platform": "node --import tsx src/__tests__/platform.ts",
77
78
  "test:batches": "node --import tsx src/__tests__/batches.ts"