euredact 0.3.1 → 0.3.3

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
@@ -4,6 +4,7 @@ exports.EuRedact = void 0;
4
4
  const cache_js_1 = require("./cache.js");
5
5
  const normalizer_js_1 = require("./normalizer.js");
6
6
  const engine_js_1 = require("./rules/engine.js");
7
+ const evidence_js_1 = require("./rules/evidence.js");
7
8
  const types_js_1 = require("./types.js");
8
9
  const DATE_TYPES = new Set([types_js_1.EntityType.DOB, types_js_1.EntityType.DATE_OF_DEATH]);
9
10
  class ReferentialMapper {
@@ -23,6 +24,32 @@ class ReferentialMapper {
23
24
  }
24
25
  }
25
26
  const DEFAULT_MAX_INPUT_LENGTH = 10_485_760; // ~10 MB of text
27
+ /**
28
+ * Reject a bare string where a list of country codes is expected.
29
+ *
30
+ * `countries: "NL"` is iterable, so it walks into the codes "N" and "L".
31
+ * Neither resolves, so nothing is declared — and every detection that *does*
32
+ * carry a country is then flagged `outOfScope`. A caller following the
33
+ * documented pattern of filtering on that field silently keeps none of them,
34
+ * while `redactedText` still looks perfectly correct. The failure direction is
35
+ * "no PII here", from a one-character typo.
36
+ *
37
+ * This already threw in Node, but by accident and only once something happened
38
+ * to call `.map` on it — deep in the call, with a message naming neither the
39
+ * argument nor the fix.
40
+ *
41
+ * A wrong *code* is data and only warns; raising there would invite callers to
42
+ * wrap redaction in try/catch and skip it. A wrong *type* is a programming
43
+ * error with no correct interpretation to fall back on.
44
+ */
45
+ function checkCountryArg(value, param) {
46
+ if (typeof value === "string") {
47
+ throw new TypeError(`${param} must be an array of country codes, not a bare string. ` +
48
+ `Pass ${param}: ["${value}"] rather than ${param}: "${value}" — a string ` +
49
+ `is iterated character by character, which silently declares nothing and ` +
50
+ `flags every detection outOfScope.`);
51
+ }
52
+ }
26
53
  class EuRedact {
27
54
  engine = new engine_js_1.RuleEngine();
28
55
  cache = new cache_js_1.ResultCache();
@@ -42,17 +69,25 @@ class EuRedact {
42
69
  this.referentialMapper.clear();
43
70
  }
44
71
  redact(text, options = {}) {
72
+ checkCountryArg(options.countries, "countries");
73
+ checkCountryArg(options.countryHint, "countryHint");
45
74
  if (text.length > this.maxInputLength) {
46
75
  throw new Error(`Input text length (${text.length.toLocaleString()} chars) exceeds the maximum ` +
47
76
  `(${this.maxInputLength.toLocaleString()} chars). Split the input or ` +
48
77
  `increase maxInputLength when constructing EuRedact.`);
49
78
  }
50
- const { countries = null, mode = "rules", referentialIntegrity = false, detectDates = false, cache = true, } = options;
79
+ const { countries = null, countryHint = null, context = null, chunkOffset = 0, mode = "rules", referentialIntegrity = false, detectDates = false, } = options;
80
+ // A context makes the result depend on evidence from other chunks, so the
81
+ // text no longer identifies the result. Caching is disabled rather than
82
+ // keyed on the context, whose contents change as chunks arrive.
83
+ const cache = context !== null ? false : (options.cache ?? true);
51
84
  const [normalizedText, offsetMapping] = (0, normalizer_js_1.normalize)(text);
52
85
  const countriesTuple = countries
53
86
  ? countries.map(c => c.toUpperCase()).sort()
54
87
  : ["ALL"];
55
- const cacheMode = `${mode}|dates=${detectDates}`;
88
+ // countryHint changes attribution, so it must key the cache too.
89
+ const hintKey = countryHint ? countryHint.map(c => c.toUpperCase()).sort().join(",") : "";
90
+ const cacheMode = `${mode}|dates=${detectDates}|hint=${hintKey}`;
56
91
  let cacheKey;
57
92
  if (cache) {
58
93
  cacheKey = this.cache.key(normalizedText, countriesTuple, cacheMode);
@@ -60,7 +95,10 @@ class EuRedact {
60
95
  if (cached !== null)
61
96
  return cached;
62
97
  }
63
- let detections = this.engine.detect(normalizedText, countries);
98
+ const { detections: rawDetections, evidence, scores, } = this.engine.detectWithEvidence(normalizedText, countries, countryHint, context !== null ? context.evidence : null);
99
+ if (context !== null)
100
+ context.add(evidence, chunkOffset);
101
+ let detections = rawDetections;
64
102
  if (offsetMapping !== null) {
65
103
  detections = detections.map(d => ({
66
104
  ...d,
@@ -80,11 +118,18 @@ class EuRedact {
80
118
  : `[${det.entityType}]`;
81
119
  redacted = redacted.slice(0, det.start) + replacement + redacted.slice(det.end);
82
120
  }
121
+ // Report the inference so it can be audited. Spans in `evidence` are
122
+ // offsets into the normalised text, matching `detections`.
123
+ const inferredCountries = [...(0, evidence_js_1.weightsToRanking)(scores).entries()]
124
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
83
125
  const result = {
84
126
  redactedText: redacted,
85
127
  detections,
86
128
  source: "rules",
87
129
  degraded: false,
130
+ inferredCountries,
131
+ evidence,
132
+ detectionMode: countries && countries.length ? "declared" : "inferred",
88
133
  };
89
134
  if (cache && cacheKey) {
90
135
  this.cache.put(cacheKey, result);
@@ -92,8 +137,9 @@ class EuRedact {
92
137
  return result;
93
138
  }
94
139
  redactBatch(texts, options = {}) {
95
- const countries = options.countries ?? null;
96
- this.engine.loadCountries(countries ? countries.map(c => c.toUpperCase()) : null);
140
+ // Generation is country-blind, so every country's patterns are loaded
141
+ // regardless of what the caller declared.
142
+ this.engine.loadCountries(null);
97
143
  return texts.map(text => this.redact(text, options));
98
144
  }
99
145
  }
@@ -1,5 +1,6 @@
1
1
  export { EntityType, DetectionSource, LEGACY_TYPE_ALIASES } from "./types.js";
2
- export type { Detection, RedactResult, PatternDef, CountryConfig } from "./types.js";
2
+ export type { Detection, RedactResult, PatternDef, CountryConfig, CountryEvidence } from "./types.js";
3
+ export { DocumentContext } from "./rules/context.js";
3
4
  export { EuRedact, type RedactOptions } from "./sdk.js";
4
5
  export { COUNTRY_CONFIGS } from "./rules/countries/index.js";
5
6
  export { setBicRegistry, getBicRegistry, SEED_BIC6_PREFIXES, type BicRegistryProvider } from "./rules/bicRegistry.js";
package/dist/esm/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { EntityType, DetectionSource, LEGACY_TYPE_ALIASES } from "./types.js";
2
+ export { DocumentContext } from "./rules/context.js";
2
3
  export { EuRedact } from "./sdk.js";
3
4
  export { COUNTRY_CONFIGS } from "./rules/countries/index.js";
4
5
  export { setBicRegistry, getBicRegistry, SEED_BIC6_PREFIXES } from "./rules/bicRegistry.js";
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Country evidence shared across the chunks of one document.
3
+ *
4
+ * A long document is usually redacted in pieces. Each piece is scanned
5
+ * independently, so each infers its own country — and a chunk that happens to
6
+ * contain no IBAN, no `+CC` number and no ccTLD infers nothing at all, even
7
+ * when the first page identified the document beyond doubt:
8
+ *
9
+ * page 1 "Factuur — IBAN NL91ABNA0417164300, info@jansen.nl"
10
+ * page 7 "Telefoon 0612345678" <- Dutch mobile, or Danish CPR?
11
+ *
12
+ * Scanned alone, page 7 has nothing to go on. Sharing a context across the two
13
+ * carries page 1's evidence forward, and the number resolves as a Dutch phone.
14
+ *
15
+ * Spans are rebased by `chunkOffset` on the way in, so evidence collected from
16
+ * a chunk points at offsets in the whole document rather than the chunk.
17
+ *
18
+ * A context influences **scoring only**. It can change which national scheme is
19
+ * attributed to a value. It can never change which spans are found, so a
20
+ * context carried over from the wrong document cannot cause a miss.
21
+ */
22
+ import type { CountryEvidence } from "../types.js";
23
+ export declare class DocumentContext {
24
+ private seen;
25
+ private items;
26
+ /** Record evidence from a chunk starting at `chunkOffset`. */
27
+ add(evidence: CountryEvidence[], chunkOffset?: number): void;
28
+ /**
29
+ * Everything recorded so far, spans rebased to the whole document.
30
+ *
31
+ * A getter, to match {@link DocumentContext.size}. These were a method and a
32
+ * getter on the same class, which is a trip hazard rather than a style
33
+ * question: `ctx.size()` and `ctx.evidence` both *succeed* and return
34
+ * something useless — a number is not callable, and a function object is
35
+ * truthy — so the mistake surfaces later as an empty result rather than as
36
+ * an error. Both are property reads now, and the wrong form throws.
37
+ *
38
+ * Returns a fresh copy each call, so mutating it cannot corrupt the context.
39
+ * (The Python SDK spells this `evidence()`, following its own idiom.)
40
+ */
41
+ get evidence(): CountryEvidence[];
42
+ get size(): number;
43
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Country evidence shared across the chunks of one document.
3
+ *
4
+ * A long document is usually redacted in pieces. Each piece is scanned
5
+ * independently, so each infers its own country — and a chunk that happens to
6
+ * contain no IBAN, no `+CC` number and no ccTLD infers nothing at all, even
7
+ * when the first page identified the document beyond doubt:
8
+ *
9
+ * page 1 "Factuur — IBAN NL91ABNA0417164300, info@jansen.nl"
10
+ * page 7 "Telefoon 0612345678" <- Dutch mobile, or Danish CPR?
11
+ *
12
+ * Scanned alone, page 7 has nothing to go on. Sharing a context across the two
13
+ * carries page 1's evidence forward, and the number resolves as a Dutch phone.
14
+ *
15
+ * Spans are rebased by `chunkOffset` on the way in, so evidence collected from
16
+ * a chunk points at offsets in the whole document rather than the chunk.
17
+ *
18
+ * A context influences **scoring only**. It can change which national scheme is
19
+ * attributed to a value. It can never change which spans are found, so a
20
+ * context carried over from the wrong document cannot cause a miss.
21
+ */
22
+ export class DocumentContext {
23
+ // Deduped on the same key evidence.collect() uses, so re-running a chunk — a
24
+ // retry, an overlapping window — cannot let one entity vote twice and
25
+ // outweigh the rest of the document.
26
+ seen = new Set();
27
+ items = [];
28
+ /** Record evidence from a chunk starting at `chunkOffset`. */
29
+ add(evidence, chunkOffset = 0) {
30
+ for (const ev of evidence) {
31
+ const span = [ev.span[0] + chunkOffset, ev.span[1] + chunkOffset];
32
+ const key = `${span[0]}:${span[1]}:${ev.source}`;
33
+ if (this.seen.has(key))
34
+ continue;
35
+ this.seen.add(key);
36
+ this.items.push(chunkOffset === 0 ? ev : { ...ev, span });
37
+ }
38
+ }
39
+ /**
40
+ * Everything recorded so far, spans rebased to the whole document.
41
+ *
42
+ * A getter, to match {@link DocumentContext.size}. These were a method and a
43
+ * getter on the same class, which is a trip hazard rather than a style
44
+ * question: `ctx.size()` and `ctx.evidence` both *succeed* and return
45
+ * something useless — a number is not callable, and a function object is
46
+ * truthy — so the mistake surfaces later as an empty result rather than as
47
+ * an error. Both are property reads now, and the wrong form throws.
48
+ *
49
+ * Returns a fresh copy each call, so mutating it cannot corrupt the context.
50
+ * (The Python SDK spells this `evidence()`, following its own idiom.)
51
+ */
52
+ get evidence() {
53
+ return [...this.items];
54
+ }
55
+ get size() {
56
+ return this.items.length;
57
+ }
58
+ }
@@ -1,4 +1,5 @@
1
- import { type Detection } from "../types.js";
1
+ import { type CountryEvidence, type Detection } from "../types.js";
2
+ import { type RawMatch } from "./matchers.js";
2
3
  /**
3
4
  * The country modules use the EU/VAT convention (UK, EL) while the public
4
5
  * contract is ISO 3166-1 alpha-2 (GB, GR). Accept both spellings so a caller
@@ -12,11 +13,34 @@ export declare class RuleEngine {
12
13
  private loadedCountries;
13
14
  addCustomPattern(name: string, pattern: string): void;
14
15
  loadCountries(countryCodes: string[] | null): void;
15
- detect(text: string, countryCodes: string[] | null): Detection[];
16
+ /**
17
+ * Generate candidate matches. Country-blind, by contract.
18
+ *
19
+ * This is the only caller of {@link MultiPatternMatcher.scan}, and it takes
20
+ * no country argument. Which country the caller declared may influence how a
21
+ * match is *scored*; it may never influence whether the match is *found*.
22
+ * Before this held, `countries: ["BE"]` made a valid Dutch BSN vanish
23
+ * entirely because the Dutch patterns were never run.
24
+ */
25
+ generateCandidates(text: string): RawMatch[];
26
+ detect(text: string, countryCodes: string[] | null, countryHint?: string[] | null): Detection[];
27
+ /**
28
+ * {@link RuleEngine.detect}, plus the country evidence it reasoned from.
29
+ *
30
+ * `countryCodes` is **scope**: it flags detections from elsewhere as
31
+ * `outOfScope` and, like `countryHint`, contributes a prior. `countryHint` is
32
+ * **only** a prior — it helps resolve ambiguity without marking anything out
33
+ * of scope. Neither gates what is looked for.
34
+ */
35
+ detectWithEvidence(text: string, countryCodes: string[] | null, countryHint?: string[] | null, priorEvidence?: CountryEvidence[] | null): {
36
+ detections: Detection[];
37
+ evidence: CountryEvidence[];
38
+ scores: Map<string, number>;
39
+ };
16
40
  /**
17
41
  * Remove overlapping detections with priority-aware resolution.
18
- * Priority: validated (3) > custom (2) > regex-only (1).
19
- * Within the same tier, longer span wins.
42
+ * Priority: validated (3) > custom (2) > regex-only (1) > postal (0) >
43
+ * demoted (-1). Within a tier, longer span wins, then country evidence.
20
44
  */
21
45
  private deduplicate;
22
46
  getLoadedCountries(): Set<string>;