euredact 0.3.0 → 0.3.2
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 +50 -0
- package/dist/cjs/rules/countries/index.js +7 -1
- package/dist/cjs/rules/deDistricts.js +92 -0
- package/dist/cjs/rules/engine.js +231 -67
- package/dist/cjs/rules/evidence.js +201 -0
- package/dist/cjs/rules/matchers.js +10 -0
- package/dist/cjs/rules/suppressors.js +40 -1
- package/dist/cjs/sdk.js +23 -5
- package/dist/esm/index.d.ts +2 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/rules/context.d.ts +31 -0
- package/dist/esm/rules/context.js +46 -0
- package/dist/esm/rules/countries/index.js +7 -1
- package/dist/esm/rules/deDistricts.d.ts +16 -0
- package/dist/esm/rules/deDistricts.js +89 -0
- package/dist/esm/rules/engine.d.ts +28 -4
- package/dist/esm/rules/engine.js +198 -67
- 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/rules/suppressors.js +40 -1
- package/dist/esm/sdk.d.ts +21 -0
- package/dist/esm/sdk.js +23 -5
- package/dist/esm/types.d.ts +53 -0
- package/package.json +5 -3
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EntityType } from "../types.js";
|
|
2
2
|
import { isRegisteredBic } from "./bicRegistry.js";
|
|
3
|
+
import { DE_DISTRICT_CODES } from "./deDistricts.js";
|
|
3
4
|
const CONTEXT_CHARS = 150;
|
|
4
5
|
function getContext(text, start, end) {
|
|
5
6
|
const ctxStart = Math.max(0, start - CONTEXT_CHARS);
|
|
@@ -117,6 +118,12 @@ function suppressPhoneDateOverlap(_text, match) {
|
|
|
117
118
|
return false;
|
|
118
119
|
return DATE_PATTERN_FULL.test(match.text.trim());
|
|
119
120
|
}
|
|
121
|
+
// Standards and classification prefixes that are plate-shaped once a letter and
|
|
122
|
+
// digits follow: "ATC-N06", "ICD-O3". None is a German district code, but the
|
|
123
|
+
// guard is still gated on the absence of a plate cue, so a genuine plate that
|
|
124
|
+
// happens to collide is not lost.
|
|
125
|
+
const STANDARDS_PREFIX = new Set(["ICD", "ISO", "DIN", "IEC", "RFC", "DSM", "ATC", "MDR"]);
|
|
126
|
+
const PLATE_CUE_NEAR = /(?:Kennzeichen|Nummernschild|Kfz|Fahrzeug|amtliche[sn]?\s+Kennz|nummerplaat|plaque\s+d'immatriculation|license\s+plate|number\s+plate)/i;
|
|
120
127
|
const NOT_CITY_CODES = new Set([
|
|
121
128
|
"ID", "NR", "NO", "ST", "DR", "MR", "MS", "HR", "FR", "IM", "IN", "OR", "IF", "IS", "IT", "AT", "AD", "AG", "AV",
|
|
122
129
|
"BE", "DE", "EU", "NL", "LU", "WS", "SS", "IP",
|
|
@@ -135,6 +142,13 @@ function suppressPlateInCompound(text, match) {
|
|
|
135
142
|
}
|
|
136
143
|
const matched = match.text.trim();
|
|
137
144
|
const parts = matched.split(/[\s\-]+/);
|
|
145
|
+
// A standards or classification reference, not a plate — unless a plate cue
|
|
146
|
+
// nearby says otherwise.
|
|
147
|
+
if (parts.length > 0 && STANDARDS_PREFIX.has(parts[0].toUpperCase())) {
|
|
148
|
+
const [b, a] = getContext(text, match.start, match.end);
|
|
149
|
+
if (!PLATE_CUE_NEAR.test(b + a))
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
138
152
|
if (parts.length > 0 && NOT_CITY_CODES.has(parts[0])) {
|
|
139
153
|
const afterChar = match.end < text.length ? text[match.end] : "";
|
|
140
154
|
const beforeChar = match.start > 0 ? text[match.start - 1] : "";
|
|
@@ -157,6 +171,31 @@ function suppressPlateInCompound(text, match) {
|
|
|
157
171
|
}
|
|
158
172
|
return false;
|
|
159
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Reject a German plate whose district code is not a real one.
|
|
176
|
+
*
|
|
177
|
+
* The district-code set is closed, which makes it a whitelist rather than the
|
|
178
|
+
* open-ended blocklist of standards prefixes: "REF-A12", "SYS-B3", "KTO-A1"
|
|
179
|
+
* and every other document reference of that shape fail it without needing to
|
|
180
|
+
* be enumerated. Applied as a tier — an unknown code still emits when a plate
|
|
181
|
+
* cue is nearby, so a missing code costs recall only in the absence of any
|
|
182
|
+
* other evidence.
|
|
183
|
+
*/
|
|
184
|
+
function suppressDePlateUnknownDistrict(text, match) {
|
|
185
|
+
if (match.patternDef.entityType !== EntityType.LICENSE_PLATE)
|
|
186
|
+
return false;
|
|
187
|
+
if (match.countryCode !== "DE")
|
|
188
|
+
return false;
|
|
189
|
+
const parts = match.text.trim().split(/[\s\-]+/);
|
|
190
|
+
if (parts.length === 0 || !parts[0])
|
|
191
|
+
return false;
|
|
192
|
+
if (DE_DISTRICT_CODES.has(parts[0].toUpperCase()))
|
|
193
|
+
return false;
|
|
194
|
+
const [before, after] = getContext(text, match.start, match.end);
|
|
195
|
+
if (PLATE_CUE_NEAR.test(before + after))
|
|
196
|
+
return false;
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
160
199
|
function suppressNatidAsPassport(text, match) {
|
|
161
200
|
if (match.patternDef.entityType !== EntityType.NATIONAL_ID)
|
|
162
201
|
return false;
|
|
@@ -426,7 +465,7 @@ const TYPE_SUPPRESSORS = {
|
|
|
426
465
|
[EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalInsideIban, suppressPostalAsHouseNumber, suppressPostalInLongerIdentifier],
|
|
427
466
|
[EntityType.BIC]: [suppressBicWithoutEvidence],
|
|
428
467
|
[EntityType.BANK_ACCOUNT]: [suppressReference],
|
|
429
|
-
[EntityType.LICENSE_PLATE]: [suppressPlateInCompound],
|
|
468
|
+
[EntityType.LICENSE_PLATE]: [suppressPlateInCompound, suppressDePlateUnknownDistrict],
|
|
430
469
|
[EntityType.CHAMBER_OF_COMMERCE]: [suppressReference],
|
|
431
470
|
};
|
|
432
471
|
export function shouldSuppress(text, match) {
|
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 {
|
|
@@ -44,12 +45,18 @@ export class EuRedact {
|
|
|
44
45
|
`(${this.maxInputLength.toLocaleString()} chars). Split the input or ` +
|
|
45
46
|
`increase maxInputLength when constructing EuRedact.`);
|
|
46
47
|
}
|
|
47
|
-
const { countries = null, mode = "rules", referentialIntegrity = false, detectDates = false,
|
|
48
|
+
const { countries = null, countryHint = null, context = null, chunkOffset = 0, mode = "rules", referentialIntegrity = false, detectDates = false, } = options;
|
|
49
|
+
// A context makes the result depend on evidence from other chunks, so the
|
|
50
|
+
// text no longer identifies the result. Caching is disabled rather than
|
|
51
|
+
// keyed on the context, whose contents change as chunks arrive.
|
|
52
|
+
const cache = context !== null ? false : (options.cache ?? true);
|
|
48
53
|
const [normalizedText, offsetMapping] = normalize(text);
|
|
49
54
|
const countriesTuple = countries
|
|
50
55
|
? countries.map(c => c.toUpperCase()).sort()
|
|
51
56
|
: ["ALL"];
|
|
52
|
-
|
|
57
|
+
// countryHint changes attribution, so it must key the cache too.
|
|
58
|
+
const hintKey = countryHint ? countryHint.map(c => c.toUpperCase()).sort().join(",") : "";
|
|
59
|
+
const cacheMode = `${mode}|dates=${detectDates}|hint=${hintKey}`;
|
|
53
60
|
let cacheKey;
|
|
54
61
|
if (cache) {
|
|
55
62
|
cacheKey = this.cache.key(normalizedText, countriesTuple, cacheMode);
|
|
@@ -57,7 +64,10 @@ export class EuRedact {
|
|
|
57
64
|
if (cached !== null)
|
|
58
65
|
return cached;
|
|
59
66
|
}
|
|
60
|
-
|
|
67
|
+
const { detections: rawDetections, evidence, scores, } = this.engine.detectWithEvidence(normalizedText, countries, countryHint, context !== null ? context.evidence() : null);
|
|
68
|
+
if (context !== null)
|
|
69
|
+
context.add(evidence, chunkOffset);
|
|
70
|
+
let detections = rawDetections;
|
|
61
71
|
if (offsetMapping !== null) {
|
|
62
72
|
detections = detections.map(d => ({
|
|
63
73
|
...d,
|
|
@@ -77,11 +87,18 @@ export class EuRedact {
|
|
|
77
87
|
: `[${det.entityType}]`;
|
|
78
88
|
redacted = redacted.slice(0, det.start) + replacement + redacted.slice(det.end);
|
|
79
89
|
}
|
|
90
|
+
// Report the inference so it can be audited. Spans in `evidence` are
|
|
91
|
+
// offsets into the normalised text, matching `detections`.
|
|
92
|
+
const inferredCountries = [...weightsToRanking(scores).entries()]
|
|
93
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
80
94
|
const result = {
|
|
81
95
|
redactedText: redacted,
|
|
82
96
|
detections,
|
|
83
97
|
source: "rules",
|
|
84
98
|
degraded: false,
|
|
99
|
+
inferredCountries,
|
|
100
|
+
evidence,
|
|
101
|
+
detectionMode: countries && countries.length ? "declared" : "inferred",
|
|
85
102
|
};
|
|
86
103
|
if (cache && cacheKey) {
|
|
87
104
|
this.cache.put(cacheKey, result);
|
|
@@ -89,8 +106,9 @@ export class EuRedact {
|
|
|
89
106
|
return result;
|
|
90
107
|
}
|
|
91
108
|
redactBatch(texts, options = {}) {
|
|
92
|
-
|
|
93
|
-
|
|
109
|
+
// Generation is country-blind, so every country's patterns are loaded
|
|
110
|
+
// regardless of what the caller declared.
|
|
111
|
+
this.engine.loadCountries(null);
|
|
94
112
|
return texts.map(text => this.redact(text, options));
|
|
95
113
|
}
|
|
96
114
|
}
|
package/dist/esm/types.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.3.2",
|
|
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,12 +53,14 @@
|
|
|
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",
|
|
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
|
-
"test:eval:full": "node --max-old-space-size=8192 --import tsx src/__tests__/evalFull.ts"
|
|
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",
|
|
63
|
+
"test:inference": "node --import tsx src/__tests__/inference.ts"
|
|
62
64
|
},
|
|
63
65
|
"devDependencies": {
|
|
64
66
|
"typescript": "^5.4.0",
|