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/README.md +213 -29
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/rules/context.js +62 -0
- package/dist/cjs/rules/engine.js +306 -66
- package/dist/cjs/rules/evidence.js +201 -0
- package/dist/cjs/rules/matchers.js +10 -0
- package/dist/cjs/sdk.js +51 -5
- package/dist/esm/index.d.ts +2 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/rules/context.d.ts +43 -0
- package/dist/esm/rules/context.js +58 -0
- package/dist/esm/rules/engine.d.ts +28 -4
- package/dist/esm/rules/engine.js +273 -66
- package/dist/esm/rules/evidence.d.ts +63 -0
- package/dist/esm/rules/evidence.js +195 -0
- package/dist/esm/rules/matchers.d.ts +10 -0
- package/dist/esm/rules/matchers.js +10 -0
- package/dist/esm/sdk.d.ts +21 -0
- package/dist/esm/sdk.js +51 -5
- package/dist/esm/types.d.ts +53 -0
- package/package.json +5 -3
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
}
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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>;
|