@desert-ant-labs/redact 0.2.0
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/LICENSE.md +136 -0
- package/README.md +143 -0
- package/dist/deterministic.d.ts +4 -0
- package/dist/deterministic.js +449 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +42 -0
- package/dist/model.d.ts +40 -0
- package/dist/model.js +177 -0
- package/dist/pipeline.d.ts +15 -0
- package/dist/pipeline.js +353 -0
- package/dist/types.d.ts +39 -0
- package/dist/types.js +1 -0
- package/package.json +59 -0
package/dist/model.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { env, AutoTokenizer, AutoModelForTokenClassification, } from "@huggingface/transformers";
|
|
2
|
+
import { deterministicDetect, OWNED_LABELS } from "./deterministic.js";
|
|
3
|
+
import { bioesToSpans, snapSpans, bridgeNameGaps, extendParticleNames, attachBuildingNumbers, redactUsStreet, attachStateCodes, redactSecondaryAddress, relabelByContext, cleanSpans, hysteresis, mergeSpansPriority, resolve, maskText, } from "./pipeline.js";
|
|
4
|
+
const MIN_SCORE = 0.6;
|
|
5
|
+
const LOW_SCORE = 0.3;
|
|
6
|
+
const MAX_LENGTH = 256;
|
|
7
|
+
const KNOWN_LABELS = new Set([
|
|
8
|
+
"GIVEN_NAME", "SURNAME", "STREET_NAME", "BUILDING_NUMBER", "SECONDARY_ADDRESS",
|
|
9
|
+
"CITY", "STATE", "ZIP_CODE", "EMAIL", "PHONE", "CREDIT_CARD", "BANK_ACCOUNT",
|
|
10
|
+
"ROUTING_NUMBER", "IP_ADDRESS", "URL", "GOVERNMENT_ID", "PASSPORT",
|
|
11
|
+
"DRIVERS_LICENSE", "TAX_ID", "SSN", "IMEI",
|
|
12
|
+
]);
|
|
13
|
+
/**
|
|
14
|
+
* On-device multilingual PII redaction. A tiny BIOES token classifier (via
|
|
15
|
+
* transformers.js / ONNX Runtime) reconciled with a dependency-free deterministic
|
|
16
|
+
* recognizer layer. Load once with {@link Redact.load} and reuse.
|
|
17
|
+
*/
|
|
18
|
+
export class Redact {
|
|
19
|
+
tokenizer;
|
|
20
|
+
model;
|
|
21
|
+
id2label;
|
|
22
|
+
specialIds;
|
|
23
|
+
constructor(tokenizer, model) {
|
|
24
|
+
this.tokenizer = tokenizer;
|
|
25
|
+
this.model = model;
|
|
26
|
+
this.id2label = model.config.id2label;
|
|
27
|
+
this.specialIds = new Set((tokenizer.all_special_ids ?? []).map(Number));
|
|
28
|
+
}
|
|
29
|
+
/** Loads the model from the Hugging Face Hub (cached) or a local directory. */
|
|
30
|
+
static async load(options = {}) {
|
|
31
|
+
const repo = options.repo ?? "desert-ant-labs/redact";
|
|
32
|
+
const revision = options.revision ?? "v0.2.0";
|
|
33
|
+
if (options.localPath) {
|
|
34
|
+
env.allowRemoteModels = false;
|
|
35
|
+
env.allowLocalModels = true;
|
|
36
|
+
env.localModelPath = options.localPath;
|
|
37
|
+
}
|
|
38
|
+
if (options.useCache === false)
|
|
39
|
+
env.useBrowserCache = false;
|
|
40
|
+
if (options.cacheDir)
|
|
41
|
+
env.cacheDir = options.cacheDir;
|
|
42
|
+
const src = options.localPath ?? repo;
|
|
43
|
+
const cfg = options.localPath ? {} : { revision };
|
|
44
|
+
const tokenizer = await AutoTokenizer.from_pretrained(src, cfg);
|
|
45
|
+
const model = await AutoModelForTokenClassification.from_pretrained(src, {
|
|
46
|
+
...cfg, dtype: (options.dtype ?? "fp32"),
|
|
47
|
+
});
|
|
48
|
+
return new Redact(tokenizer, model);
|
|
49
|
+
}
|
|
50
|
+
// Reconstruct char offsets for each subword by aligning the metaspace-prefixed
|
|
51
|
+
// surface against the source (the XLM-R unigram tokenizer keeps it verbatim).
|
|
52
|
+
reconstructOffsets(text) {
|
|
53
|
+
const pieces = this.tokenizer.tokenize(text);
|
|
54
|
+
let cursor = 0;
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const piece of pieces) {
|
|
57
|
+
const core = piece.replace(/^\u2581/, "");
|
|
58
|
+
if (core === "") {
|
|
59
|
+
out.push([cursor, cursor]);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const idx = text.indexOf(core, cursor);
|
|
63
|
+
if (idx < 0) {
|
|
64
|
+
out.push([cursor, cursor]);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
out.push([idx, idx + core.length]);
|
|
68
|
+
cursor = idx + core.length;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
async mlSpans(text, minScore) {
|
|
73
|
+
const enc = this.tokenizer(text, { truncation: true, max_length: MAX_LENGTH });
|
|
74
|
+
const ids = enc.input_ids.tolist()[0];
|
|
75
|
+
const out = await this.model({ input_ids: enc.input_ids, attention_mask: enc.attention_mask });
|
|
76
|
+
const { dims, data } = out.logits;
|
|
77
|
+
const T = dims[1], C = dims[2];
|
|
78
|
+
const bestId = new Array(T);
|
|
79
|
+
const bestProb = new Array(T);
|
|
80
|
+
for (let t = 0; t < T; t++) {
|
|
81
|
+
let mx = -Infinity;
|
|
82
|
+
for (let c = 0; c < C; c++) {
|
|
83
|
+
const v = data[t * C + c];
|
|
84
|
+
if (v > mx)
|
|
85
|
+
mx = v;
|
|
86
|
+
}
|
|
87
|
+
let sum = 0, top = 0, tv = -Infinity;
|
|
88
|
+
for (let c = 0; c < C; c++) {
|
|
89
|
+
sum += Math.exp(data[t * C + c] - mx);
|
|
90
|
+
if (data[t * C + c] > tv) {
|
|
91
|
+
tv = data[t * C + c];
|
|
92
|
+
top = c;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
bestId[t] = top;
|
|
96
|
+
bestProb[t] = Math.exp(tv - mx) / sum;
|
|
97
|
+
}
|
|
98
|
+
const pieceOffsets = this.reconstructOffsets(text);
|
|
99
|
+
const offsets = new Array(T);
|
|
100
|
+
let pi = 0;
|
|
101
|
+
for (let t = 0; t < T; t++) {
|
|
102
|
+
offsets[t] = this.specialIds.has(Number(ids[t])) ? [0, 0] : (pieceOffsets[pi++] ?? [0, 0]);
|
|
103
|
+
}
|
|
104
|
+
const low = Math.min(LOW_SCORE, minScore);
|
|
105
|
+
const tags = bestId.map((id, t) => (bestProb[t] >= low ? this.id2label[id] : "O"));
|
|
106
|
+
const scored = [];
|
|
107
|
+
for (const span of bioesToSpans(tags, offsets)) {
|
|
108
|
+
let mx = 0;
|
|
109
|
+
for (let t = 0; t < T; t++) {
|
|
110
|
+
const [a, b] = offsets[t];
|
|
111
|
+
if (b > a && Math.max(a, span.start) < Math.min(b, span.end))
|
|
112
|
+
mx = Math.max(mx, bestProb[t]);
|
|
113
|
+
}
|
|
114
|
+
scored.push([span, mx]);
|
|
115
|
+
}
|
|
116
|
+
let kept = hysteresis(text, scored, minScore);
|
|
117
|
+
kept = mergeSpansPriority(kept);
|
|
118
|
+
kept = attachBuildingNumbers(text, extendParticleNames(text, bridgeNameGaps(text, snapSpans(text, kept))));
|
|
119
|
+
kept = redactSecondaryAddress(text, attachStateCodes(text, redactUsStreet(text, kept)));
|
|
120
|
+
return mergeSpansPriority(kept);
|
|
121
|
+
}
|
|
122
|
+
/** Detect PII spans (the full deterministic-owner hybrid pipeline). */
|
|
123
|
+
async detect(text, options = {}) {
|
|
124
|
+
const minScore = options.minimumConfidence ?? MIN_SCORE;
|
|
125
|
+
const det = deterministicDetect(text, OWNED_LABELS);
|
|
126
|
+
const ml = await this.mlSpans(maskText(text, det), minScore);
|
|
127
|
+
const corr = deterministicDetect(text, ["PHONE"]).filter((s) => !OWNED_LABELS.has(s.label));
|
|
128
|
+
let spans = cleanSpans(text, relabelByContext(text, resolve(det.concat(corr), ml)));
|
|
129
|
+
if (options.labels) {
|
|
130
|
+
const allow = new Set(options.labels);
|
|
131
|
+
spans = spans.filter((s) => allow.has(s.label));
|
|
132
|
+
}
|
|
133
|
+
return spans;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Detect and redact the PII in `text`. Each entity is replaced by a unique,
|
|
137
|
+
* numbered placeholder (`[EMAIL_1]`, `[GIVEN_NAME_1]`, ...) so the result is
|
|
138
|
+
* safe to hand to an LLM and restore afterwards via {@link Redaction.restore}.
|
|
139
|
+
*/
|
|
140
|
+
async redaction(text, options = {}) {
|
|
141
|
+
const spans = (await this.detect(text, options))
|
|
142
|
+
.slice()
|
|
143
|
+
.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
144
|
+
const counts = {};
|
|
145
|
+
const items = [];
|
|
146
|
+
let out = "";
|
|
147
|
+
let last = 0;
|
|
148
|
+
for (const span of spans) {
|
|
149
|
+
if (!KNOWN_LABELS.has(span.label))
|
|
150
|
+
continue;
|
|
151
|
+
const s = span.start, e = span.end;
|
|
152
|
+
if (s < last || s >= e || e > text.length)
|
|
153
|
+
continue; // in order, non-overlapping
|
|
154
|
+
const n = (counts[span.label] ?? 0) + 1;
|
|
155
|
+
counts[span.label] = n;
|
|
156
|
+
const placeholder = `[${span.label}_${n}]`;
|
|
157
|
+
items.push({
|
|
158
|
+
label: span.label,
|
|
159
|
+
original: text.slice(s, e),
|
|
160
|
+
placeholder,
|
|
161
|
+
confidence: span.score ?? 1,
|
|
162
|
+
start: s,
|
|
163
|
+
end: e,
|
|
164
|
+
});
|
|
165
|
+
out += text.slice(last, s) + placeholder;
|
|
166
|
+
last = e;
|
|
167
|
+
}
|
|
168
|
+
out += text.slice(last);
|
|
169
|
+
const restore = (processed) => {
|
|
170
|
+
let r = processed;
|
|
171
|
+
for (const item of items)
|
|
172
|
+
r = r.split(item.placeholder).join(item.original);
|
|
173
|
+
return r;
|
|
174
|
+
};
|
|
175
|
+
return { redactedText: out, items, restore };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Span } from "./types.js";
|
|
2
|
+
export declare function bioesToSpans(tags: string[], offsets: [number, number][]): Span[];
|
|
3
|
+
export declare function snapSpans(text: string, spans: Span[]): Span[];
|
|
4
|
+
export declare function bridgeNameGaps(text: string, spans: Span[]): Span[];
|
|
5
|
+
export declare function extendParticleNames(text: string, spans: Span[]): Span[];
|
|
6
|
+
export declare function attachBuildingNumbers(text: string, spans: Span[]): Span[];
|
|
7
|
+
export declare function redactUsStreet(text: string, spans: Span[]): Span[];
|
|
8
|
+
export declare function attachStateCodes(text: string, spans: Span[]): Span[];
|
|
9
|
+
export declare function redactSecondaryAddress(text: string, spans: Span[]): Span[];
|
|
10
|
+
export declare function relabelByContext(text: string, spans: Span[]): Span[];
|
|
11
|
+
export declare function cleanSpans(text: string, spans: Span[]): Span[];
|
|
12
|
+
export declare function hysteresis(text: string, scored: [Span, number][], high: number): Span[];
|
|
13
|
+
export declare function mergeSpansPriority(spans: Span[]): Span[];
|
|
14
|
+
export declare function resolve(detSpans: Span[], mlSpans: Span[]): Span[];
|
|
15
|
+
export declare function maskText(text: string, spans: Span[], maskChar?: string): string;
|
package/dist/pipeline.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// Port of redact_training.pipeline + labels.bioes_to_spans. Span post-processing
|
|
2
|
+
// (BIOES decode, word snapping, name bridging, deterministic-owner merge).
|
|
3
|
+
const NAME_FAMILIES = new Set(["GIVEN_NAME", "SURNAME"]);
|
|
4
|
+
const CONNECT = "-'\u2019";
|
|
5
|
+
function splitTag(t) {
|
|
6
|
+
if (t === "O")
|
|
7
|
+
return ["O", null];
|
|
8
|
+
const i = t.indexOf("-");
|
|
9
|
+
return [t.slice(0, i), t.slice(i + 1)];
|
|
10
|
+
}
|
|
11
|
+
// ── BIOES → char spans (tolerant) ────────────────────────────────────────
|
|
12
|
+
export function bioesToSpans(tags, offsets) {
|
|
13
|
+
const out = [];
|
|
14
|
+
let label = null, start = null, end = null;
|
|
15
|
+
const close = () => {
|
|
16
|
+
if (label !== null && start !== null && end !== null && end > start)
|
|
17
|
+
out.push({ start, end, label });
|
|
18
|
+
label = start = end = null;
|
|
19
|
+
};
|
|
20
|
+
for (let i = 0; i < tags.length; i++) {
|
|
21
|
+
const [prefix, lab] = splitTag(tags[i]);
|
|
22
|
+
const [a, b] = offsets[i];
|
|
23
|
+
if (b <= a)
|
|
24
|
+
continue;
|
|
25
|
+
if (prefix === "O") {
|
|
26
|
+
close();
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (prefix === "S") {
|
|
30
|
+
close();
|
|
31
|
+
out.push({ start: a, end: b, label: lab });
|
|
32
|
+
}
|
|
33
|
+
else if (prefix === "B") {
|
|
34
|
+
close();
|
|
35
|
+
label = lab;
|
|
36
|
+
start = a;
|
|
37
|
+
end = b;
|
|
38
|
+
}
|
|
39
|
+
else if (prefix === "I") {
|
|
40
|
+
if (label === lab)
|
|
41
|
+
end = b;
|
|
42
|
+
else {
|
|
43
|
+
close();
|
|
44
|
+
label = lab;
|
|
45
|
+
start = a;
|
|
46
|
+
end = b;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else if (prefix === "E") {
|
|
50
|
+
if (label === lab) {
|
|
51
|
+
end = b;
|
|
52
|
+
close();
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
close();
|
|
56
|
+
out.push({ start: a, end: b, label: lab });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
close();
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
const isWordChar = (c) => /[\p{L}\p{N}]/u.test(c) || /\p{M}/u.test(c);
|
|
64
|
+
function snapOne(text, s, e) {
|
|
65
|
+
const n = text.length;
|
|
66
|
+
s = Math.max(0, Math.min(s, n));
|
|
67
|
+
e = Math.max(0, Math.min(e, n));
|
|
68
|
+
while (s > 0) {
|
|
69
|
+
const c = text[s - 1];
|
|
70
|
+
if (isWordChar(c))
|
|
71
|
+
s--;
|
|
72
|
+
else if (CONNECT.includes(c) && s - 2 >= 0 && isWordChar(text[s - 2]))
|
|
73
|
+
s--;
|
|
74
|
+
else
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
while (e < n) {
|
|
78
|
+
const c = text[e];
|
|
79
|
+
if (isWordChar(c))
|
|
80
|
+
e++;
|
|
81
|
+
else if (CONNECT.includes(c) && e + 1 < n && isWordChar(text[e + 1]))
|
|
82
|
+
e++;
|
|
83
|
+
else
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
return [s, e];
|
|
87
|
+
}
|
|
88
|
+
function mergeSameLabel(spans) {
|
|
89
|
+
const ordered = spans.slice().sort((a, b) => a.start - b.start || (b.end - b.start) - (a.end - a.start) || a.label.localeCompare(b.label));
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const s of ordered) {
|
|
92
|
+
if (out.length && s.label === out[out.length - 1].label && s.start <= out[out.length - 1].end)
|
|
93
|
+
out[out.length - 1] = { start: out[out.length - 1].start, end: Math.max(out[out.length - 1].end, s.end), label: s.label };
|
|
94
|
+
else if (!out.length || s.start >= out[out.length - 1].end)
|
|
95
|
+
out.push(s);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
export function snapSpans(text, spans) {
|
|
100
|
+
return mergeSameLabel(spans.map((sp) => {
|
|
101
|
+
const [s, e] = snapOne(text, sp.start, sp.end);
|
|
102
|
+
return { start: s, end: e, label: sp.label };
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
const PARTICLES = new Set(["de", "del", "della", "dell", "di", "da", "das", "dos", "du",
|
|
106
|
+
"van", "von", "der", "den", "ter", "la", "le", "el", "al", "bin", "ibn", "mac", "mc",
|
|
107
|
+
"o", "st", "of", "y", "e"]);
|
|
108
|
+
const ALL_PARTICLES = new Set(["van", "von", "de", "del", "della", "dell", "di", "da", "das",
|
|
109
|
+
"dos", "du", "zu", "af", "ter", "ten", "des", "do", "der", "den", "la", "le", "el", "y"]);
|
|
110
|
+
function gapIsNameLike(gap) {
|
|
111
|
+
if (!gap.trim())
|
|
112
|
+
return false;
|
|
113
|
+
if (gap.length > 20 || /[,;:/&|()\[\]{}"<>\n\t]/.test(gap))
|
|
114
|
+
return false;
|
|
115
|
+
for (const tok of gap.split(/\s+/)) {
|
|
116
|
+
const w = tok.replace(/^[.'\u2019-]+|[.'\u2019-]+$/g, "");
|
|
117
|
+
if (!w)
|
|
118
|
+
continue;
|
|
119
|
+
if (PARTICLES.has(w.toLowerCase()) || w.length === 1 || w[0] === w[0].toUpperCase())
|
|
120
|
+
continue;
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
export function bridgeNameGaps(text, spans) {
|
|
126
|
+
const ordered = spans.slice().sort((a, b) => a.start - b.start || a.end - b.end);
|
|
127
|
+
const out = [];
|
|
128
|
+
for (const s of ordered) {
|
|
129
|
+
const p = out[out.length - 1];
|
|
130
|
+
if (p && NAME_FAMILIES.has(p.label) && NAME_FAMILIES.has(s.label) &&
|
|
131
|
+
s.start >= p.end && gapIsNameLike(text.slice(p.end, s.start)))
|
|
132
|
+
out[out.length - 1] = { start: p.start, end: Math.max(p.end, s.end), label: p.label };
|
|
133
|
+
else
|
|
134
|
+
out.push(s);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
export function extendParticleNames(text, spans) {
|
|
139
|
+
const ordered = spans.slice().sort((a, b) => a.start - b.start || a.end - b.end);
|
|
140
|
+
const out = [];
|
|
141
|
+
const NEXT = /^(\s+)([^\s,.;:!?)\]}"]+)/;
|
|
142
|
+
for (let sp of ordered) {
|
|
143
|
+
if (NAME_FAMILIES.has(sp.label)) {
|
|
144
|
+
const tw = /([^\s]+)\s*$/.exec(text.slice(sp.start, sp.end));
|
|
145
|
+
let consumed = !!(tw && ALL_PARTICLES.has(tw[1].replace(/^[.'\u2019]+|[.'\u2019]+$/g, "").toLowerCase()));
|
|
146
|
+
let pos = sp.end;
|
|
147
|
+
while (true) {
|
|
148
|
+
const m = NEXT.exec(text.slice(pos));
|
|
149
|
+
if (!m)
|
|
150
|
+
break;
|
|
151
|
+
const tok = m[2];
|
|
152
|
+
if (ALL_PARTICLES.has(tok.replace(/^[.'\u2019]+|[.'\u2019]+$/g, "").toLowerCase())) {
|
|
153
|
+
pos += m[0].length;
|
|
154
|
+
consumed = true;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (consumed && tok[0] === tok[0].toUpperCase() && tok[0] !== tok[0].toLowerCase())
|
|
158
|
+
pos += m[0].length;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
if (pos > sp.end)
|
|
162
|
+
sp = { start: sp.start, end: pos, label: sp.label };
|
|
163
|
+
}
|
|
164
|
+
out.push(sp);
|
|
165
|
+
}
|
|
166
|
+
return mergeSameLabel(out);
|
|
167
|
+
}
|
|
168
|
+
export function attachBuildingNumbers(text, spans) {
|
|
169
|
+
const out = spans.map((s) => ({ ...s }));
|
|
170
|
+
const occ = out.map((s) => [s.start, s.end]);
|
|
171
|
+
const free = (a, b) => !occ.some(([x, y]) => x < b && a < y);
|
|
172
|
+
const AFTER = /^[\s,]{0,2}(\d{1,5}[a-zA-Z]?(?:[-/]\d{1,4}[a-zA-Z]?)?)\b/;
|
|
173
|
+
const BEFORE = /(\d{1,5}[a-zA-Z]?)[\s,]{0,2}$/;
|
|
174
|
+
for (const s of spans) {
|
|
175
|
+
if (s.label !== "STREET_NAME")
|
|
176
|
+
continue;
|
|
177
|
+
const m = AFTER.exec(text.slice(s.end));
|
|
178
|
+
if (m) {
|
|
179
|
+
const a = s.end + m.index + m[0].indexOf(m[1]), b = a + m[1].length;
|
|
180
|
+
if (free(a, b)) {
|
|
181
|
+
out.push({ start: a, end: b, label: "BUILDING_NUMBER" });
|
|
182
|
+
occ.push([a, b]);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const base = Math.max(0, s.start - 8);
|
|
186
|
+
const m2 = BEFORE.exec(text.slice(base, s.start));
|
|
187
|
+
if (m2) {
|
|
188
|
+
const a = base + m2.index, b = a + m2[1].length;
|
|
189
|
+
if (free(a, b)) {
|
|
190
|
+
out.push({ start: a, end: b, label: "BUILDING_NUMBER" });
|
|
191
|
+
occ.push([a, b]);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return mergeSameLabel(out).sort((a, b) => a.start - b.start || a.end - b.end);
|
|
196
|
+
}
|
|
197
|
+
// US street / secondary-address / state-code recognizers (mirror pipeline.py and
|
|
198
|
+
// Pipeline.swift): the model often leaves these in the clear, so redact them
|
|
199
|
+
// deterministically.
|
|
200
|
+
const US_STREET_RE = /\b(\d{1,6}[A-Za-z]?)\s+((?:[A-Z][A-Za-z0-9.'’-]*\s+){0,4}(?:Street|Avenue|Boulevard|Road|Lane|Drive|Court|Place|Terrace|Circle|Highway|Parkway|Square|Trail|Crescent|Alley|Loop|Way|St|Ave|Blvd|Rd|Ln|Dr|Ct|Pl|Ter|Cir|Hwy|Pkwy|Sq|Trl|Aly))\b\.?(?=$|[\s,.;:)])/gd;
|
|
201
|
+
const STATE_ZIP_RE = /(?:,\s*|\s)([A-Z]{2})\s+(\d{5}(?:-\d{4})?)\b/gd;
|
|
202
|
+
const SEC_ADDR_RE = /\b(?:Apartment|Apt|Suite|Ste|Unit|Building|Bldg|Floor|Fl|Room|Rm|Department|Dept|Trailer|Trlr|Space|Spc|Lot)\.?\s*#?\s*(?:\d{1,4}[A-Za-z]?|[A-Za-z]\d{1,4})\b/gi;
|
|
203
|
+
const US_STATES = new Set(("AL AK AZ AR CA CO CT DE DC FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS "
|
|
204
|
+
+ "MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY").split(" "));
|
|
205
|
+
export function redactUsStreet(text, spans) {
|
|
206
|
+
let out = spans.map((s) => ({ ...s }));
|
|
207
|
+
for (const m of text.matchAll(US_STREET_RE)) {
|
|
208
|
+
const [bnS, bnE] = m.indices[1], [stS, stE] = m.indices[2];
|
|
209
|
+
out = out.filter((s) => !((s.label === "STREET_NAME" || s.label === "BUILDING_NUMBER") && Math.max(s.start, bnS) < Math.min(s.end, stE)));
|
|
210
|
+
out.push({ start: bnS, end: bnE, label: "BUILDING_NUMBER" });
|
|
211
|
+
out.push({ start: stS, end: stE, label: "STREET_NAME" });
|
|
212
|
+
}
|
|
213
|
+
return mergeSameLabel(out).sort((a, b) => a.start - b.start || a.end - b.end);
|
|
214
|
+
}
|
|
215
|
+
export function attachStateCodes(text, spans) {
|
|
216
|
+
const out = spans.map((s) => ({ ...s }));
|
|
217
|
+
const occ = out.map((s) => [s.start, s.end]);
|
|
218
|
+
const add = (a, b, label) => { if (!occ.some(([x, y]) => x < b && a < y)) {
|
|
219
|
+
out.push({ start: a, end: b, label });
|
|
220
|
+
occ.push([a, b]);
|
|
221
|
+
} };
|
|
222
|
+
for (const m of text.matchAll(STATE_ZIP_RE)) {
|
|
223
|
+
const [sS, sE] = m.indices[1], [zS, zE] = m.indices[2];
|
|
224
|
+
if (US_STATES.has(text.slice(sS, sE))) {
|
|
225
|
+
add(sS, sE, "STATE");
|
|
226
|
+
add(zS, zE, "ZIP_CODE");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return mergeSameLabel(out).sort((a, b) => a.start - b.start || a.end - b.end);
|
|
230
|
+
}
|
|
231
|
+
export function redactSecondaryAddress(text, spans) {
|
|
232
|
+
let out = spans.map((s) => ({ ...s }));
|
|
233
|
+
for (const m of text.matchAll(SEC_ADDR_RE)) {
|
|
234
|
+
const s = m.index, e = m.index + m[0].length;
|
|
235
|
+
out = out.filter((sp) => !((sp.label === "SECONDARY_ADDRESS" || sp.label === "BUILDING_NUMBER") && Math.max(sp.start, s) < Math.min(sp.end, e)));
|
|
236
|
+
out.push({ start: s, end: e, label: "SECONDARY_ADDRESS" });
|
|
237
|
+
}
|
|
238
|
+
return mergeSameLabel(out).sort((a, b) => a.start - b.start || a.end - b.end);
|
|
239
|
+
}
|
|
240
|
+
// Final label/text tidy - mirrors pipeline.py relabel_by_context + clean_spans.
|
|
241
|
+
const TRIM_TRAIL = " \t\n\r.,;:!?)]}\"\u00bb\u2019\u201d'";
|
|
242
|
+
const TRIM_LEAD = " \t\n\r([{\"\u00ab\u2018\u201c";
|
|
243
|
+
const ACCT_LEFT = /(?:\ba\/?c\b|acct|account|konto|compte|cuenta|rekening|conta|\biban\b)\W{0,4}#?\s*$/i;
|
|
244
|
+
const STRIP_TITLES = new Set(["mr", "mrs", "ms", "miss", "mx", "master", "mstr", "dr", "prof", "professor",
|
|
245
|
+
"doctor", "dear", "sir", "madam", "madame", "monsieur", "mme", "mlle", "herr", "frau",
|
|
246
|
+
"fraulein", "frl", "mevrouw", "dhr", "signor", "signora"]);
|
|
247
|
+
export function relabelByContext(text, spans) {
|
|
248
|
+
return spans.map((s) => {
|
|
249
|
+
let lab = s.label;
|
|
250
|
+
const left = text.slice(Math.max(0, s.start - 28), s.start).toLowerCase();
|
|
251
|
+
if (lab === "PHONE" && ACCT_LEFT.test(left))
|
|
252
|
+
lab = "BANK_ACCOUNT";
|
|
253
|
+
else if (lab === "GOVERNMENT_ID" && ((left.includes("driv") && left.includes("licen"))
|
|
254
|
+
|| left.includes("f\u00fchrerschein") || left.includes("fuhrerschein") || left.includes("rijbewijs")
|
|
255
|
+
|| (left.includes("permis") && left.includes("conduire"))))
|
|
256
|
+
lab = "DRIVERS_LICENSE";
|
|
257
|
+
return { start: s.start, end: s.end, label: lab };
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
export function cleanSpans(text, spans) {
|
|
261
|
+
const out = [];
|
|
262
|
+
for (const s of spans) {
|
|
263
|
+
let st = s.start, en = s.end;
|
|
264
|
+
while (en > st && TRIM_TRAIL.includes(text[en - 1]))
|
|
265
|
+
en--;
|
|
266
|
+
while (st < en && TRIM_LEAD.includes(text[st]))
|
|
267
|
+
st++;
|
|
268
|
+
if (NAME_FAMILIES.has(s.label)) {
|
|
269
|
+
while (st < en) {
|
|
270
|
+
const m = /^(\S+)\s+/.exec(text.slice(st, en));
|
|
271
|
+
if (!m || !STRIP_TITLES.has(m[1].replace(/^[.'\u2019]+|[.'\u2019]+$/g, "").toLowerCase()))
|
|
272
|
+
break;
|
|
273
|
+
st += m[0].length;
|
|
274
|
+
}
|
|
275
|
+
const core = text.slice(st, en).replace(/^[.'\u2019 ]+|[.'\u2019 ]+$/g, "");
|
|
276
|
+
if (!core || STRIP_TITLES.has(core.toLowerCase()))
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (en > st)
|
|
280
|
+
out.push({ start: st, end: en, label: s.label });
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
function adjacentName(text, a, b) {
|
|
285
|
+
const [lo, hi] = a.start <= b.start ? [a, b] : [b, a];
|
|
286
|
+
if (hi.start < lo.end)
|
|
287
|
+
return true;
|
|
288
|
+
const gap = text.slice(lo.end, hi.start);
|
|
289
|
+
return (!gap.trim() && gap.length <= 3) || gapIsNameLike(gap);
|
|
290
|
+
}
|
|
291
|
+
export function hysteresis(text, scored, high) {
|
|
292
|
+
const kept = scored.filter(([, sc]) => sc >= high).map(([sp]) => sp);
|
|
293
|
+
const weak = scored.filter(([sp, sc]) => sc < high && NAME_FAMILIES.has(sp.label)).map(([sp]) => sp);
|
|
294
|
+
let changed = true;
|
|
295
|
+
while (changed && weak.length) {
|
|
296
|
+
changed = false;
|
|
297
|
+
for (const sp of [...weak]) {
|
|
298
|
+
if (kept.some((k) => NAME_FAMILIES.has(k.label) && adjacentName(text, sp, k))) {
|
|
299
|
+
kept.push(sp);
|
|
300
|
+
weak.splice(weak.indexOf(sp), 1);
|
|
301
|
+
changed = true;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return kept;
|
|
306
|
+
}
|
|
307
|
+
const DET_OWNED = new Set(["SSN", "CREDIT_CARD", "EMAIL", "URL", "IP_ADDRESS", "BANK_ACCOUNT", "ROUTING_NUMBER"]);
|
|
308
|
+
export function mergeSpansPriority(spans) {
|
|
309
|
+
const prio = (s) => [DET_OWNED.has(s.label) ? 2 : 1, s.end - s.start];
|
|
310
|
+
const gt = (a, b) => a[0] > b[0] || (a[0] === b[0] && a[1] > b[1]);
|
|
311
|
+
const ordered = spans.slice().sort((a, b) => a.start - b.start || (b.end - b.start) - (a.end - a.start) || a.label.localeCompare(b.label));
|
|
312
|
+
const out = [];
|
|
313
|
+
for (const s of ordered) {
|
|
314
|
+
if (out.length && s.label === out[out.length - 1].label && s.start <= out[out.length - 1].end)
|
|
315
|
+
out[out.length - 1] = { start: out[out.length - 1].start, end: Math.max(out[out.length - 1].end, s.end), label: s.label };
|
|
316
|
+
else if (!out.length || s.start >= out[out.length - 1].end)
|
|
317
|
+
out.push(s);
|
|
318
|
+
else if (gt(prio(s), prio(out[out.length - 1])))
|
|
319
|
+
out[out.length - 1] = s;
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
const overlaps = (a0, a1, b0, b1) => Math.max(a0, b0) < Math.min(a1, b1);
|
|
324
|
+
export function resolve(detSpans, mlSpans) {
|
|
325
|
+
const det = detSpans.map((s) => ({ ...s })), ml = mlSpans.map((s) => ({ ...s }));
|
|
326
|
+
const suppressed = new Set();
|
|
327
|
+
const keptMl = [];
|
|
328
|
+
for (const m of ml) {
|
|
329
|
+
let conflict = false;
|
|
330
|
+
for (let i = 0; i < det.length; i++) {
|
|
331
|
+
if (overlaps(m.start, m.end, det[i].start, det[i].end)) {
|
|
332
|
+
if (m.label === det[i].label)
|
|
333
|
+
suppressed.add(i);
|
|
334
|
+
else
|
|
335
|
+
conflict = true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (!conflict)
|
|
339
|
+
keptMl.push(m);
|
|
340
|
+
}
|
|
341
|
+
const keptDet = det.filter((_, i) => !suppressed.has(i));
|
|
342
|
+
return mergeSameLabel(keptDet.concat(keptMl)).sort((a, b) => a.start - b.start || a.end - b.end || a.label.localeCompare(b.label));
|
|
343
|
+
}
|
|
344
|
+
export function maskText(text, spans, maskChar) {
|
|
345
|
+
if (!text)
|
|
346
|
+
return text;
|
|
347
|
+
const chars = text.split("");
|
|
348
|
+
for (const s of spans)
|
|
349
|
+
for (let i = Math.max(0, s.start); i < Math.min(chars.length, s.end); i++)
|
|
350
|
+
if (chars[i] !== "\n")
|
|
351
|
+
chars[i] = " ";
|
|
352
|
+
return chars.join("");
|
|
353
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** A detected span with character offsets into the source text. */
|
|
2
|
+
export interface Span {
|
|
3
|
+
start: number;
|
|
4
|
+
end: number;
|
|
5
|
+
label: string;
|
|
6
|
+
score?: number;
|
|
7
|
+
}
|
|
8
|
+
/** The 20 model labels plus the deterministic-only `IMEI` label. */
|
|
9
|
+
export type RedactLabel = "GIVEN_NAME" | "SURNAME" | "STREET_NAME" | "BUILDING_NUMBER" | "SECONDARY_ADDRESS" | "CITY" | "STATE" | "ZIP_CODE" | "EMAIL" | "PHONE" | "CREDIT_CARD" | "BANK_ACCOUNT" | "ROUTING_NUMBER" | "IP_ADDRESS" | "URL" | "GOVERNMENT_ID" | "PASSPORT" | "DRIVERS_LICENSE" | "TAX_ID" | "SSN" | "IMEI";
|
|
10
|
+
/** A single redacted entity, with its placeholder and original value. */
|
|
11
|
+
export interface RedactionItem {
|
|
12
|
+
/** PII category, e.g. `"EMAIL"`. */
|
|
13
|
+
label: string;
|
|
14
|
+
/** The matched sensitive text. */
|
|
15
|
+
original: string;
|
|
16
|
+
/** Numbered placeholder, e.g. `"[EMAIL_1]"`. */
|
|
17
|
+
placeholder: string;
|
|
18
|
+
/** Confidence in `0..1` (deterministic recognizers report `1`). */
|
|
19
|
+
confidence: number;
|
|
20
|
+
/** Character offsets of `original` in the source text. */
|
|
21
|
+
start: number;
|
|
22
|
+
end: number;
|
|
23
|
+
}
|
|
24
|
+
/** The result of a redaction: masked text, the detections, and a restore helper. */
|
|
25
|
+
export interface Redaction {
|
|
26
|
+
/** The input with every detection replaced by a `[LABEL_N]` placeholder. */
|
|
27
|
+
redactedText: string;
|
|
28
|
+
/** Every detection, in document order. */
|
|
29
|
+
items: RedactionItem[];
|
|
30
|
+
/** Fill original values back into text that still contains the placeholders. */
|
|
31
|
+
restore(processed: string): string;
|
|
32
|
+
}
|
|
33
|
+
/** Detection options. */
|
|
34
|
+
export interface Options {
|
|
35
|
+
/** Neural confidence threshold, `0..1`. Default `0.6`. Deterministic recognizers always apply. */
|
|
36
|
+
minimumConfidence?: number;
|
|
37
|
+
/** Restrict detection to these labels. Omit to detect every category. */
|
|
38
|
+
labels?: Iterable<string>;
|
|
39
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@desert-ant-labs/redact",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "On-device multilingual PII redaction: detect and mask names, addresses, emails, phones, cards, IBANs, national IDs, VAT numbers and more across 24 EU languages.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./deterministic": {
|
|
15
|
+
"types": "./dist/deterministic.d.ts",
|
|
16
|
+
"default": "./dist/deterministic.js"
|
|
17
|
+
},
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"test": "node --import tsx --test test/*.test.ts",
|
|
26
|
+
"prepare": "npm run build"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"pii",
|
|
30
|
+
"redaction",
|
|
31
|
+
"privacy",
|
|
32
|
+
"gdpr",
|
|
33
|
+
"on-device",
|
|
34
|
+
"multilingual",
|
|
35
|
+
"token-classification",
|
|
36
|
+
"transformers.js"
|
|
37
|
+
],
|
|
38
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/Desert-Ant-Labs/redact-js.git"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/Desert-Ant-Labs/redact-js",
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18"
|
|
46
|
+
},
|
|
47
|
+
"sideEffects": false,
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@huggingface/transformers": "^4.2.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/node": "^22",
|
|
56
|
+
"tsx": "^4",
|
|
57
|
+
"typescript": "^5"
|
|
58
|
+
}
|
|
59
|
+
}
|