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.
@@ -7,6 +7,16 @@ export declare class MultiPatternMatcher {
7
7
  addPattern(patternDef: PatternDef, countryCode: string): void;
8
8
  addCountry(config: CountryConfig): void;
9
9
  compile(): void;
10
+ /**
11
+ * Scan text against patterns.
12
+ *
13
+ * `countryCodes` restricts which patterns run. **The engine never passes
14
+ * it.** Generation is country-blind by contract — see
15
+ * `RuleEngine.generateCandidates` and `src/__tests__/inference.ts` — because
16
+ * gating generation on the caller's declared country caused silent misses.
17
+ * The parameter is kept for a future opt-in fast mode, which would be the
18
+ * only caller permitted to trade recall for speed.
19
+ */
10
20
  scan(text: string, countryCodes?: Set<string> | null): RawMatch[];
11
21
  validate(match: RawMatch): boolean;
12
22
  }
@@ -19,6 +19,16 @@ export class MultiPatternMatcher {
19
19
  compile() {
20
20
  this.compiled = true;
21
21
  }
22
+ /**
23
+ * Scan text against patterns.
24
+ *
25
+ * `countryCodes` restricts which patterns run. **The engine never passes
26
+ * it.** Generation is country-blind by contract — see
27
+ * `RuleEngine.generateCandidates` and `src/__tests__/inference.ts` — because
28
+ * gating generation on the caller's declared country caused silent misses.
29
+ * The parameter is kept for a future opt-in fast mode, which would be the
30
+ * only caller permitted to trade recall for speed.
31
+ */
22
32
  scan(text, countryCodes = null) {
23
33
  if (!this.compiled)
24
34
  this.compile();
package/dist/esm/sdk.d.ts CHANGED
@@ -1,6 +1,27 @@
1
+ import { DocumentContext } from "./rules/context.js";
1
2
  import { type RedactResult } from "./types.js";
2
3
  export interface RedactOptions {
4
+ /**
5
+ * Scope. Detections attributed elsewhere are flagged `outOfScope`, never
6
+ * dropped, and this also acts as a prior when resolving which national
7
+ * scheme owns an ambiguous value. It does **not** gate what is looked for.
8
+ */
3
9
  countries?: string[] | null;
10
+ /**
11
+ * A prior only. Helps resolve ambiguity without narrowing scope or flagging
12
+ * anything out of scope.
13
+ */
14
+ countryHint?: string[] | null;
15
+ /**
16
+ * Shares country evidence across the chunks of one document, so a chunk
17
+ * carrying no country signal of its own is still scored against what the
18
+ * rest of the document showed. Pass the same object for every chunk, with
19
+ * `chunkOffset` set to where the chunk starts in the whole document.
20
+ */
21
+ context?: DocumentContext | null;
22
+ /** Offset of this chunk within the document. Used only to rebase spans
23
+ * recorded in `context`; returned detections are relative to `text`. */
24
+ chunkOffset?: number;
4
25
  mode?: string;
5
26
  referentialIntegrity?: boolean;
6
27
  detectDates?: boolean;
package/dist/esm/sdk.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ResultCache } from "./cache.js";
2
2
  import { normalize, mapOffsetToOriginal } from "./normalizer.js";
3
3
  import { RuleEngine } from "./rules/engine.js";
4
+ import { weightsToRanking } from "./rules/evidence.js";
4
5
  import { EntityType } from "./types.js";
5
6
  const DATE_TYPES = new Set([EntityType.DOB, EntityType.DATE_OF_DEATH]);
6
7
  class ReferentialMapper {
@@ -20,6 +21,32 @@ class ReferentialMapper {
20
21
  }
21
22
  }
22
23
  const DEFAULT_MAX_INPUT_LENGTH = 10_485_760; // ~10 MB of text
24
+ /**
25
+ * Reject a bare string where a list of country codes is expected.
26
+ *
27
+ * `countries: "NL"` is iterable, so it walks into the codes "N" and "L".
28
+ * Neither resolves, so nothing is declared — and every detection that *does*
29
+ * carry a country is then flagged `outOfScope`. A caller following the
30
+ * documented pattern of filtering on that field silently keeps none of them,
31
+ * while `redactedText` still looks perfectly correct. The failure direction is
32
+ * "no PII here", from a one-character typo.
33
+ *
34
+ * This already threw in Node, but by accident and only once something happened
35
+ * to call `.map` on it — deep in the call, with a message naming neither the
36
+ * argument nor the fix.
37
+ *
38
+ * A wrong *code* is data and only warns; raising there would invite callers to
39
+ * wrap redaction in try/catch and skip it. A wrong *type* is a programming
40
+ * error with no correct interpretation to fall back on.
41
+ */
42
+ function checkCountryArg(value, param) {
43
+ if (typeof value === "string") {
44
+ throw new TypeError(`${param} must be an array of country codes, not a bare string. ` +
45
+ `Pass ${param}: ["${value}"] rather than ${param}: "${value}" — a string ` +
46
+ `is iterated character by character, which silently declares nothing and ` +
47
+ `flags every detection outOfScope.`);
48
+ }
49
+ }
23
50
  export class EuRedact {
24
51
  engine = new RuleEngine();
25
52
  cache = new ResultCache();
@@ -39,17 +66,25 @@ export class EuRedact {
39
66
  this.referentialMapper.clear();
40
67
  }
41
68
  redact(text, options = {}) {
69
+ checkCountryArg(options.countries, "countries");
70
+ checkCountryArg(options.countryHint, "countryHint");
42
71
  if (text.length > this.maxInputLength) {
43
72
  throw new Error(`Input text length (${text.length.toLocaleString()} chars) exceeds the maximum ` +
44
73
  `(${this.maxInputLength.toLocaleString()} chars). Split the input or ` +
45
74
  `increase maxInputLength when constructing EuRedact.`);
46
75
  }
47
- const { countries = null, mode = "rules", referentialIntegrity = false, detectDates = false, cache = true, } = options;
76
+ const { countries = null, countryHint = null, context = null, chunkOffset = 0, mode = "rules", referentialIntegrity = false, detectDates = false, } = options;
77
+ // A context makes the result depend on evidence from other chunks, so the
78
+ // text no longer identifies the result. Caching is disabled rather than
79
+ // keyed on the context, whose contents change as chunks arrive.
80
+ const cache = context !== null ? false : (options.cache ?? true);
48
81
  const [normalizedText, offsetMapping] = normalize(text);
49
82
  const countriesTuple = countries
50
83
  ? countries.map(c => c.toUpperCase()).sort()
51
84
  : ["ALL"];
52
- const cacheMode = `${mode}|dates=${detectDates}`;
85
+ // countryHint changes attribution, so it must key the cache too.
86
+ const hintKey = countryHint ? countryHint.map(c => c.toUpperCase()).sort().join(",") : "";
87
+ const cacheMode = `${mode}|dates=${detectDates}|hint=${hintKey}`;
53
88
  let cacheKey;
54
89
  if (cache) {
55
90
  cacheKey = this.cache.key(normalizedText, countriesTuple, cacheMode);
@@ -57,7 +92,10 @@ export class EuRedact {
57
92
  if (cached !== null)
58
93
  return cached;
59
94
  }
60
- let detections = this.engine.detect(normalizedText, countries);
95
+ const { detections: rawDetections, evidence, scores, } = this.engine.detectWithEvidence(normalizedText, countries, countryHint, context !== null ? context.evidence : null);
96
+ if (context !== null)
97
+ context.add(evidence, chunkOffset);
98
+ let detections = rawDetections;
61
99
  if (offsetMapping !== null) {
62
100
  detections = detections.map(d => ({
63
101
  ...d,
@@ -77,11 +115,18 @@ export class EuRedact {
77
115
  : `[${det.entityType}]`;
78
116
  redacted = redacted.slice(0, det.start) + replacement + redacted.slice(det.end);
79
117
  }
118
+ // Report the inference so it can be audited. Spans in `evidence` are
119
+ // offsets into the normalised text, matching `detections`.
120
+ const inferredCountries = [...weightsToRanking(scores).entries()]
121
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
80
122
  const result = {
81
123
  redactedText: redacted,
82
124
  detections,
83
125
  source: "rules",
84
126
  degraded: false,
127
+ inferredCountries,
128
+ evidence,
129
+ detectionMode: countries && countries.length ? "declared" : "inferred",
85
130
  };
86
131
  if (cache && cacheKey) {
87
132
  this.cache.put(cacheKey, result);
@@ -89,8 +134,9 @@ export class EuRedact {
89
134
  return result;
90
135
  }
91
136
  redactBatch(texts, options = {}) {
92
- const countries = options.countries ?? null;
93
- this.engine.loadCountries(countries ? countries.map(c => c.toUpperCase()) : null);
137
+ // Generation is country-blind, so every country's patterns are loaded
138
+ // regardless of what the caller declared.
139
+ this.engine.loadCountries(null);
94
140
  return texts.map(text => this.redact(text, options));
95
141
  }
96
142
  }
@@ -49,6 +49,24 @@ export declare enum DetectionSource {
49
49
  RULES = "rules",
50
50
  CLOUD = "cloud"
51
51
  }
52
+ /**
53
+ * One reason to believe a document belongs to a country.
54
+ *
55
+ * Emitted by entities that carry their country in the string — an IBAN's
56
+ * country code, a `+CC` dialling prefix, a VAT prefix, an email ccTLD.
57
+ * Auditable by design: every inference traces back to the span and the source
58
+ * that produced it.
59
+ */
60
+ export interface CountryEvidence {
61
+ /** ISO 3166-1 alpha-2. */
62
+ country: string;
63
+ /** Which signal produced this — "ibanPrefix", "e164Prefix", ... */
64
+ source: string;
65
+ /** Natural-log odds. Derived from the corpus, not hand-tuned. */
66
+ logOdds: number;
67
+ /** Offsets into the text that was scanned. */
68
+ span: [number, number];
69
+ }
52
70
  export interface Detection {
53
71
  entityType: EntityType | string;
54
72
  start: number;
@@ -57,12 +75,47 @@ export interface Detection {
57
75
  source: DetectionSource;
58
76
  country: string | null;
59
77
  confidence: string;
78
+ /**
79
+ * How strongly the document supports {@link Detection.country}, in [0, 1].
80
+ *
81
+ * 0 when no country was attributed or nothing corroborated it — which is the
82
+ * signal that an attribution rests on a checksum alone.
83
+ */
84
+ countryConfidence?: number;
85
+ /**
86
+ * True when attributed to a country the caller did not declare.
87
+ *
88
+ * `countries` narrows *scoring*, never detection. An entity from outside the
89
+ * declared set is emitted and flagged, not dropped: callers assert context,
90
+ * they do not suppress evidence.
91
+ */
92
+ outOfScope?: boolean;
60
93
  }
61
94
  export interface RedactResult {
62
95
  redactedText: string;
63
96
  detections: Detection[];
64
97
  source: string;
65
98
  degraded: boolean;
99
+ /**
100
+ * Countries this document appears to belong to, strongest first, as
101
+ * `[country, confidence]` with confidence in [0, 1].
102
+ *
103
+ * Inferred from entities that carry their country in the string. Reported so
104
+ * the inference can be audited; it influences which national scheme is
105
+ * attributed to an ambiguous value, never which spans are found.
106
+ */
107
+ inferredCountries: Array<[string, number]>;
108
+ /** Every signal behind {@link RedactResult.inferredCountries}, with the span
109
+ * that produced it. The audit trail for a country attribution. */
110
+ evidence: CountryEvidence[];
111
+ /**
112
+ * `"declared"` when the caller passed `countries`, `"inferred"` when the
113
+ * country was worked out from the content.
114
+ *
115
+ * Named `detectionMode` rather than `mode` because `redact({ mode })`
116
+ * already means the tier selector, which {@link RedactResult.source} reports.
117
+ */
118
+ detectionMode: string;
66
119
  }
67
120
  export interface PatternDef {
68
121
  entityType: EntityType | string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euredact",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "European PII redaction SDK -- fast, zero-dependency, rule-based detection and redaction of personal data across 31 European countries",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -53,13 +53,15 @@
53
53
  "build:cjs": "tsc -p tsconfig.cjs.json",
54
54
  "postbuild:cjs": "echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json",
55
55
  "build": "rm -rf dist && npm run build:esm && npm run build:cjs",
56
- "test": "npm run test:spec && npm run test:conformance",
56
+ "test": "npm run test:spec && npm run test:inference && npm run test:conformance",
57
57
  "benchmark": "node --import tsx src/__tests__/benchmark.ts",
58
58
  "prepublishOnly": "npm run build",
59
59
  "test:spec": "node --import tsx src/__tests__/spec.ts",
60
60
  "test:eval": "node --import tsx src/__tests__/run.ts",
61
61
  "test:eval:full": "node --max-old-space-size=8192 --import tsx src/__tests__/evalFull.ts",
62
- "test:conformance": "node --import tsx src/__tests__/conformance.ts"
62
+ "test:conformance": "node --import tsx src/__tests__/conformance.ts",
63
+ "test:inference": "node --import tsx src/__tests__/inference.ts",
64
+ "dump": "node --import tsx src/__tests__/dump.ts"
63
65
  },
64
66
  "devDependencies": {
65
67
  "typescript": "^5.4.0",