@doccloak/core 0.8.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/CHANGELOG.md +35 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +230 -0
- package/dist/doc.d.ts +13 -0
- package/dist/doc.js +683 -0
- package/dist/docx.d.ts +31 -0
- package/dist/docx.js +26 -0
- package/dist/dom/docx.d.ts +90 -0
- package/dist/dom/docx.js +472 -0
- package/dist/dom/index.d.ts +11 -0
- package/dist/dom/index.js +11 -0
- package/dist/dom/ocr.d.ts +112 -0
- package/dist/dom/ocr.js +224 -0
- package/dist/engine.d.ts +81 -0
- package/dist/engine.js +274 -0
- package/dist/env.d.ts +49 -0
- package/dist/env.js +53 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +29 -0
- package/dist/model-loader.d.ts +55 -0
- package/dist/model-loader.js +253 -0
- package/dist/pipeline.d.ts +19 -0
- package/dist/pipeline.js +128 -0
- package/dist/providers/bardsai.d.ts +38 -0
- package/dist/providers/bardsai.js +393 -0
- package/dist/providers/gliner.d.ts +52 -0
- package/dist/providers/gliner.js +360 -0
- package/dist/regex/index.d.ts +17 -0
- package/dist/regex/index.js +18 -0
- package/dist/regex/loader.d.ts +44 -0
- package/dist/regex/loader.js +44 -0
- package/dist/regex/rules.data.d.ts +2 -0
- package/dist/regex/rules.data.js +2671 -0
- package/dist/regex/types.d.ts +27 -0
- package/dist/regex/types.js +1 -0
- package/dist/regex/validators.d.ts +17 -0
- package/dist/regex/validators.js +492 -0
- package/dist/session.d.ts +59 -0
- package/dist/session.js +153 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.js +28 -0
- package/dist/worker-protocol.d.ts +119 -0
- package/dist/worker-protocol.js +285 -0
- package/package.json +75 -0
- package/rules/at.json +70 -0
- package/rules/be.json +70 -0
- package/rules/ch.json +66 -0
- package/rules/cn.json +155 -0
- package/rules/de.json +192 -0
- package/rules/dk.json +43 -0
- package/rules/es.json +158 -0
- package/rules/fi.json +43 -0
- package/rules/fr.json +178 -0
- package/rules/gb.json +194 -0
- package/rules/ie.json +48 -0
- package/rules/it.json +116 -0
- package/rules/jp.json +136 -0
- package/rules/nl.json +99 -0
- package/rules/no.json +132 -0
- package/rules/pl.json +210 -0
- package/rules/pt.json +113 -0
- package/rules/schema.json +91 -0
- package/rules/se.json +149 -0
- package/rules/universal.json +279 -0
- package/rules/us.json +218 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BardS.ai EU PII Anonymization Provider
|
|
3
|
+
*
|
|
4
|
+
* XLM-RoBERTa-base fine-tuned for EU PII detection (24 EU languages, natively multilingual).
|
|
5
|
+
* 35 entity types, ~0.3B parameters, quantized ONNX (~279 MB).
|
|
6
|
+
* Model: bardsai/eu-pii-anonimization-multilang
|
|
7
|
+
* Runs in-browser via ONNX Runtime WebAssembly.
|
|
8
|
+
*
|
|
9
|
+
* Environment-agnostic: wasm paths, model bytes (blob cache + fetch) and the
|
|
10
|
+
* tokenizer all arrive through the injected CoreEnv. This module never touches
|
|
11
|
+
* import.meta, caches, navigator or @huggingface/transformers directly.
|
|
12
|
+
*/
|
|
13
|
+
import * as ort from 'onnxruntime-web';
|
|
14
|
+
import { fetchModelBlob, retryAsync } from "../model-loader.js";
|
|
15
|
+
// ── Model config ──────────────────────────────────────────
|
|
16
|
+
const MODEL_URL = 'https://huggingface.co/bardsai/eu-pii-anonimization-multilang/resolve/main/onnx/model_quantized.onnx';
|
|
17
|
+
const TOKENIZER_HF = 'bardsai/eu-pii-anonimization-multilang';
|
|
18
|
+
const MODEL_NAME = 'BardS.ai EU PII';
|
|
19
|
+
const DEFAULT_THRESHOLD = 0.5;
|
|
20
|
+
const MAX_SEQ_LENGTH = 512;
|
|
21
|
+
const CHUNK_OVERLAP = 30;
|
|
22
|
+
/** id2label - 35 entity types from bardsai eu-pii-anonimization-multilang config.json (69 labels) */
|
|
23
|
+
const ID2LABEL = {
|
|
24
|
+
0: 'O',
|
|
25
|
+
1: 'B-ACCOUNT_IDENTIFIER', 2: 'B-AUTH_SECRET', 3: 'B-BANK_ACCOUNT_IDENTIFIER',
|
|
26
|
+
4: 'B-BIOMETRIC_DATA', 5: 'B-CONTACT_HANDLE', 6: 'B-CRIMINAL_OFFENCE_DATA',
|
|
27
|
+
7: 'B-DATE_OF_BIRTH', 8: 'B-DEVICE_IDENTIFIER', 9: 'B-DOCUMENT_IDENTIFIER',
|
|
28
|
+
10: 'B-DOCUMENT_REFERENCE', 11: 'B-EMAIL_ADDRESS', 12: 'B-ETHNIC_ORIGIN',
|
|
29
|
+
13: 'B-FINANCIAL_AMOUNT', 14: 'B-GEO_LOCATION', 15: 'B-HEALTH_DATA',
|
|
30
|
+
16: 'B-IDENTIFYING_LINK', 17: 'B-IP_ADDRESS', 18: 'B-LOCATION',
|
|
31
|
+
19: 'B-ORGANIZATION_IDENTIFIER', 20: 'B-ORGANIZATION_NAME', 21: 'B-PAYMENT_CARD',
|
|
32
|
+
22: 'B-PAYMENT_CARD_SECURITY', 23: 'B-PERSON_ALIAS', 24: 'B-PERSON_ATTRIBUTE',
|
|
33
|
+
25: 'B-PERSON_IDENTIFIER', 26: 'B-PERSON_NAME', 27: 'B-PERSON_ROLE_OR_TITLE',
|
|
34
|
+
28: 'B-PHONE_NUMBER', 29: 'B-POLITICAL_OPINION', 30: 'B-POSTAL_ADDRESS',
|
|
35
|
+
31: 'B-PROPER_NAME', 32: 'B-RELIGION_OR_BELIEF', 33: 'B-SEXUAL_ORIENTATION',
|
|
36
|
+
34: 'B-TRADE_UNION_MEMBERSHIP', 35: 'B-VEHICLE_IDENTIFIER',
|
|
37
|
+
36: 'I-ACCOUNT_IDENTIFIER', 37: 'I-AUTH_SECRET', 38: 'I-BANK_ACCOUNT_IDENTIFIER',
|
|
38
|
+
39: 'I-BIOMETRIC_DATA', 40: 'I-CRIMINAL_OFFENCE_DATA', 41: 'I-DATE_OF_BIRTH',
|
|
39
|
+
42: 'I-DEVICE_IDENTIFIER', 43: 'I-DOCUMENT_IDENTIFIER', 44: 'I-DOCUMENT_REFERENCE',
|
|
40
|
+
45: 'I-EMAIL_ADDRESS', 46: 'I-ETHNIC_ORIGIN', 47: 'I-FINANCIAL_AMOUNT',
|
|
41
|
+
48: 'I-GEO_LOCATION', 49: 'I-HEALTH_DATA', 50: 'I-IDENTIFYING_LINK',
|
|
42
|
+
51: 'I-IP_ADDRESS', 52: 'I-LOCATION', 53: 'I-ORGANIZATION_IDENTIFIER',
|
|
43
|
+
54: 'I-ORGANIZATION_NAME', 55: 'I-PAYMENT_CARD', 56: 'I-PAYMENT_CARD_SECURITY',
|
|
44
|
+
57: 'I-PERSON_ATTRIBUTE', 58: 'I-PERSON_IDENTIFIER', 59: 'I-PERSON_NAME',
|
|
45
|
+
60: 'I-PERSON_ROLE_OR_TITLE', 61: 'I-PHONE_NUMBER', 62: 'I-POLITICAL_OPINION',
|
|
46
|
+
63: 'I-POSTAL_ADDRESS', 64: 'I-PROPER_NAME', 65: 'I-RELIGION_OR_BELIEF',
|
|
47
|
+
66: 'I-SEXUAL_ORIENTATION', 67: 'I-TRADE_UNION_MEMBERSHIP', 68: 'I-VEHICLE_IDENTIFIER',
|
|
48
|
+
};
|
|
49
|
+
/** Map bardsai entity labels → DocCloak EntityType */
|
|
50
|
+
const LABEL_TO_ENTITY_TYPE = {
|
|
51
|
+
PERSON_NAME: 'PERSON',
|
|
52
|
+
PERSON_ALIAS: 'PERSON',
|
|
53
|
+
PROPER_NAME: 'PERSON',
|
|
54
|
+
EMAIL_ADDRESS: 'EMAIL',
|
|
55
|
+
CONTACT_HANDLE: 'EMAIL',
|
|
56
|
+
PHONE_NUMBER: 'PHONE',
|
|
57
|
+
PERSON_IDENTIFIER: 'SSN',
|
|
58
|
+
DATE_OF_BIRTH: 'DATE',
|
|
59
|
+
PAYMENT_CARD: 'CREDIT_CARD',
|
|
60
|
+
PAYMENT_CARD_SECURITY: 'CREDIT_CARD',
|
|
61
|
+
IP_ADDRESS: 'IP_ADDRESS',
|
|
62
|
+
BANK_ACCOUNT_IDENTIFIER: 'IBAN',
|
|
63
|
+
ACCOUNT_IDENTIFIER: 'IBAN',
|
|
64
|
+
POSTAL_ADDRESS: 'ADDRESS',
|
|
65
|
+
LOCATION: 'ADDRESS',
|
|
66
|
+
GEO_LOCATION: 'ADDRESS',
|
|
67
|
+
ORGANIZATION_NAME: 'COMPANY',
|
|
68
|
+
ORGANIZATION_IDENTIFIER: 'COMPANY',
|
|
69
|
+
FINANCIAL_AMOUNT: 'OTHER',
|
|
70
|
+
VEHICLE_IDENTIFIER: 'OTHER',
|
|
71
|
+
DOCUMENT_REFERENCE: 'OTHER',
|
|
72
|
+
DOCUMENT_IDENTIFIER: 'OTHER',
|
|
73
|
+
IDENTIFYING_LINK: 'OTHER',
|
|
74
|
+
PERSON_ROLE_OR_TITLE: 'OTHER',
|
|
75
|
+
PERSON_ATTRIBUTE: 'OTHER',
|
|
76
|
+
AUTH_SECRET: 'OTHER',
|
|
77
|
+
DEVICE_IDENTIFIER: 'OTHER',
|
|
78
|
+
BIOMETRIC_DATA: 'OTHER',
|
|
79
|
+
HEALTH_DATA: 'OTHER',
|
|
80
|
+
CRIMINAL_OFFENCE_DATA: 'OTHER',
|
|
81
|
+
ETHNIC_ORIGIN: 'OTHER',
|
|
82
|
+
POLITICAL_OPINION: 'OTHER',
|
|
83
|
+
RELIGION_OR_BELIEF: 'OTHER',
|
|
84
|
+
SEXUAL_ORIENTATION: 'OTHER',
|
|
85
|
+
TRADE_UNION_MEMBERSHIP: 'OTHER',
|
|
86
|
+
};
|
|
87
|
+
/** Extract the bare entity name from a BIO tag like "B-PERSON_NAME" → "PERSON_NAME" */
|
|
88
|
+
function bioToEntity(tag) {
|
|
89
|
+
if (tag === 'O' || !tag.includes('-'))
|
|
90
|
+
return null;
|
|
91
|
+
return tag.split('-').slice(1).join('-');
|
|
92
|
+
}
|
|
93
|
+
export class BardsaiProvider {
|
|
94
|
+
get name() { return this._name; }
|
|
95
|
+
constructor(env) {
|
|
96
|
+
this._name = MODEL_NAME;
|
|
97
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
98
|
+
this.tokenizer = null;
|
|
99
|
+
this.session = null;
|
|
100
|
+
this.loading = false;
|
|
101
|
+
this.loadError = null;
|
|
102
|
+
this.loadWaiters = [];
|
|
103
|
+
this.progressCallback = null;
|
|
104
|
+
this.threshold = DEFAULT_THRESHOLD;
|
|
105
|
+
this.env = env;
|
|
106
|
+
}
|
|
107
|
+
isLoaded() {
|
|
108
|
+
return this.session !== null && this.tokenizer !== null;
|
|
109
|
+
}
|
|
110
|
+
isLoading() {
|
|
111
|
+
return this.loading;
|
|
112
|
+
}
|
|
113
|
+
onProgress(callback) {
|
|
114
|
+
this.progressCallback = callback;
|
|
115
|
+
}
|
|
116
|
+
setThreshold(value) {
|
|
117
|
+
this.threshold = Math.max(0.05, Math.min(0.95, value));
|
|
118
|
+
}
|
|
119
|
+
getThreshold() {
|
|
120
|
+
return this.threshold;
|
|
121
|
+
}
|
|
122
|
+
async load() {
|
|
123
|
+
if (this.isLoaded())
|
|
124
|
+
return;
|
|
125
|
+
if (this.loadError)
|
|
126
|
+
throw this.loadError;
|
|
127
|
+
if (this.loading) {
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
this.loadWaiters.push({ resolve, reject });
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
this.loading = true;
|
|
133
|
+
try {
|
|
134
|
+
ort.env.wasm.wasmPaths = this.env.wasm.paths;
|
|
135
|
+
// Default to single-threaded WASM. ORT's multi-threaded path hangs silently
|
|
136
|
+
// in some cross-origin-isolated browser contexts when spawning pthread
|
|
137
|
+
// workers (model downloads but session.create never resolves).
|
|
138
|
+
ort.env.wasm.numThreads = this.env.wasm.numThreads ?? 1;
|
|
139
|
+
const loaderEnv = {
|
|
140
|
+
cache: this.env.modelCache,
|
|
141
|
+
fetch: this.env.fetch,
|
|
142
|
+
persistStorage: this.env.persistStorage,
|
|
143
|
+
};
|
|
144
|
+
// Load tokenizer and model in parallel (skip tokenizer if already loaded from a prior session)
|
|
145
|
+
const tasks = [
|
|
146
|
+
fetchModelBlob(loaderEnv, MODEL_URL, (downloaded, total) => this.progressCallback?.(downloaded, total)),
|
|
147
|
+
];
|
|
148
|
+
if (!this.tokenizer) {
|
|
149
|
+
tasks.push(retryAsync(() => this.env.loadTokenizer(TOKENIZER_HF), 'Tokenizer download').then((t) => {
|
|
150
|
+
this.tokenizer = t;
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
const [modelBlob] = await Promise.all(tasks);
|
|
154
|
+
const blobUrl = URL.createObjectURL(modelBlob);
|
|
155
|
+
try {
|
|
156
|
+
this.session = await ort.InferenceSession.create(blobUrl, {
|
|
157
|
+
executionProviders: ['wasm'],
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
// ONNX Runtime has already read the data into the WASM heap
|
|
162
|
+
URL.revokeObjectURL(blobUrl);
|
|
163
|
+
}
|
|
164
|
+
console.info(`[DocCloak] Model loaded: ${this._name} | inputs: [${this.session.inputNames.join(', ')}] | outputs: [${this.session.outputNames.join(', ')}]`);
|
|
165
|
+
this.loading = false;
|
|
166
|
+
this.loadWaiters.forEach((w) => w.resolve());
|
|
167
|
+
this.loadWaiters.length = 0;
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
this.session = null;
|
|
171
|
+
this.loadError = err instanceof Error ? err : new Error(String(err));
|
|
172
|
+
this.loading = false;
|
|
173
|
+
this.loadWaiters.forEach((w) => w.reject(this.loadError));
|
|
174
|
+
this.loadWaiters.length = 0;
|
|
175
|
+
throw this.loadError;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
release() {
|
|
179
|
+
if (this.session) {
|
|
180
|
+
this.session.release();
|
|
181
|
+
this.session = null;
|
|
182
|
+
}
|
|
183
|
+
this.loading = false;
|
|
184
|
+
this.loadError = null;
|
|
185
|
+
}
|
|
186
|
+
async detect(text, onProgress) {
|
|
187
|
+
if (!this.isLoaded())
|
|
188
|
+
await this.load();
|
|
189
|
+
if (!this.session || !this.tokenizer)
|
|
190
|
+
return [];
|
|
191
|
+
if (!text.trim())
|
|
192
|
+
return [];
|
|
193
|
+
// Split into words with character offsets
|
|
194
|
+
const words = [];
|
|
195
|
+
const wordRegex = /\S+/g;
|
|
196
|
+
let match;
|
|
197
|
+
while ((match = wordRegex.exec(text)) !== null) {
|
|
198
|
+
words.push({ word: match[0], start: match.index, end: match.index + match[0].length });
|
|
199
|
+
}
|
|
200
|
+
if (words.length === 0)
|
|
201
|
+
return [];
|
|
202
|
+
// Tokenize full text once to count subtokens per word via ▁ prefix
|
|
203
|
+
onProgress?.(0);
|
|
204
|
+
const fullEncoded = await this.tokenizer(words.map((w) => w.word).join(' '), {
|
|
205
|
+
add_special_tokens: false,
|
|
206
|
+
});
|
|
207
|
+
const tokenIds = Array.from(fullEncoded.input_ids.data);
|
|
208
|
+
const tokenStrings = this.tokenizer.model.convert_ids_to_tokens(tokenIds.map(Number));
|
|
209
|
+
// Count subtokens per word: ▁ prefix marks word boundaries (SentencePiece)
|
|
210
|
+
const subtokenCounts = [];
|
|
211
|
+
let currentCount = 0;
|
|
212
|
+
for (const tok of tokenStrings) {
|
|
213
|
+
if (tok.startsWith('\u2581') && currentCount > 0) {
|
|
214
|
+
subtokenCounts.push(currentCount);
|
|
215
|
+
currentCount = 1;
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
currentCount++;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (currentCount > 0)
|
|
222
|
+
subtokenCounts.push(currentCount);
|
|
223
|
+
// Pad if mismatch (rare edge case)
|
|
224
|
+
while (subtokenCounts.length < words.length)
|
|
225
|
+
subtokenCounts.push(1);
|
|
226
|
+
onProgress?.(0.1);
|
|
227
|
+
// Count total chunks for progress tracking
|
|
228
|
+
const maxSubtokens = MAX_SEQ_LENGTH - 2;
|
|
229
|
+
const overlapWords = CHUNK_OVERLAP;
|
|
230
|
+
// Pre-count chunks
|
|
231
|
+
let totalChunks = 0;
|
|
232
|
+
{
|
|
233
|
+
let ci = 0;
|
|
234
|
+
while (ci < words.length) {
|
|
235
|
+
let sum = 0, ce = ci;
|
|
236
|
+
while (ce < words.length && sum + subtokenCounts[ce] <= maxSubtokens) {
|
|
237
|
+
sum += subtokenCounts[ce];
|
|
238
|
+
ce++;
|
|
239
|
+
}
|
|
240
|
+
if (ce === ci)
|
|
241
|
+
ce = ci + 1;
|
|
242
|
+
totalChunks++;
|
|
243
|
+
const next = Math.max(ci + 1, ce - overlapWords);
|
|
244
|
+
if (ce >= words.length)
|
|
245
|
+
break;
|
|
246
|
+
ci = next;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// Process chunks with progress
|
|
250
|
+
const allEntities = [];
|
|
251
|
+
let i = 0;
|
|
252
|
+
let chunksDone = 0;
|
|
253
|
+
onProgress?.(0.1); // tokenization done
|
|
254
|
+
while (i < words.length) {
|
|
255
|
+
let subtokenSum = 0;
|
|
256
|
+
let end = i;
|
|
257
|
+
while (end < words.length && subtokenSum + subtokenCounts[end] <= maxSubtokens) {
|
|
258
|
+
subtokenSum += subtokenCounts[end];
|
|
259
|
+
end++;
|
|
260
|
+
}
|
|
261
|
+
if (end === i)
|
|
262
|
+
end = i + 1;
|
|
263
|
+
const chunkWords = words.slice(i, end);
|
|
264
|
+
const chunkEntities = await this.inferChunk(chunkWords, text);
|
|
265
|
+
allEntities.push(...chunkEntities);
|
|
266
|
+
chunksDone++;
|
|
267
|
+
onProgress?.(0.1 + 0.9 * (chunksDone / totalChunks)); // inference = 10-100%
|
|
268
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
269
|
+
const nextStart = Math.max(i + 1, end - overlapWords);
|
|
270
|
+
i = nextStart;
|
|
271
|
+
if (end >= words.length)
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
return this.deduplicateSpans(allEntities);
|
|
275
|
+
}
|
|
276
|
+
async inferChunk(words, fullText) {
|
|
277
|
+
const chunkText = words.map((w) => w.word).join(' ');
|
|
278
|
+
const encoded = await this.tokenizer(chunkText, {
|
|
279
|
+
return_tensors: 'np',
|
|
280
|
+
truncation: true,
|
|
281
|
+
max_length: MAX_SEQ_LENGTH,
|
|
282
|
+
padding: true,
|
|
283
|
+
});
|
|
284
|
+
const inputIds = encoded.input_ids.data;
|
|
285
|
+
const attentionMask = encoded.attention_mask.data;
|
|
286
|
+
const seqLen = encoded.input_ids.dims[1];
|
|
287
|
+
// Build word→subtoken alignment via ▁ prefix (no per-word tokenization needed)
|
|
288
|
+
const chunkTokenIds = Array.from(inputIds);
|
|
289
|
+
const chunkTokenStrings = this.tokenizer.model.convert_ids_to_tokens(chunkTokenIds.map(Number));
|
|
290
|
+
const wordSubtokenStart = [];
|
|
291
|
+
// Skip <s> at position 0; first real token at position 1
|
|
292
|
+
for (let t = 1; t < seqLen - 1; t++) {
|
|
293
|
+
const tok = chunkTokenStrings[t];
|
|
294
|
+
if (!tok || tok === '</s>')
|
|
295
|
+
break;
|
|
296
|
+
if (tok.startsWith('\u2581')) {
|
|
297
|
+
wordSubtokenStart.push(t);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// XLM-RoBERTa: only input_ids + attention_mask (no token_type_ids)
|
|
301
|
+
const feeds = {
|
|
302
|
+
input_ids: new ort.Tensor('int64', inputIds, [1, seqLen]),
|
|
303
|
+
attention_mask: new ort.Tensor('int64', attentionMask, [1, seqLen]),
|
|
304
|
+
};
|
|
305
|
+
const results = await this.session.run(feeds);
|
|
306
|
+
const logits = results.logits.data;
|
|
307
|
+
const numLabels = results.logits.dims[2];
|
|
308
|
+
// Per-subtoken predictions: argmax + softmax confidence
|
|
309
|
+
const predictions = [];
|
|
310
|
+
for (let t = 0; t < seqLen; t++) {
|
|
311
|
+
const offset = t * numLabels;
|
|
312
|
+
let maxIdx = 0;
|
|
313
|
+
let maxVal = logits[offset];
|
|
314
|
+
for (let l = 1; l < numLabels; l++) {
|
|
315
|
+
if (logits[offset + l] > maxVal) {
|
|
316
|
+
maxVal = logits[offset + l];
|
|
317
|
+
maxIdx = l;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
let sumExp = 0;
|
|
321
|
+
for (let l = 0; l < numLabels; l++) {
|
|
322
|
+
sumExp += Math.exp(logits[offset + l] - maxVal);
|
|
323
|
+
}
|
|
324
|
+
predictions.push({ labelIdx: maxIdx, confidence: 1 / sumExp });
|
|
325
|
+
}
|
|
326
|
+
// Aggregate subtoken predictions to word level (first subtoken wins)
|
|
327
|
+
const entities = [];
|
|
328
|
+
let currentEntity = null;
|
|
329
|
+
for (let wi = 0; wi < words.length; wi++) {
|
|
330
|
+
const stIdx = wordSubtokenStart[wi];
|
|
331
|
+
if (stIdx === undefined || stIdx >= seqLen - 1)
|
|
332
|
+
break;
|
|
333
|
+
const pred = predictions[stIdx];
|
|
334
|
+
const label = ID2LABEL[pred.labelIdx] || 'O';
|
|
335
|
+
const entityName = bioToEntity(label);
|
|
336
|
+
const isB = label.startsWith('B-');
|
|
337
|
+
const isI = label.startsWith('I-');
|
|
338
|
+
if (isB) {
|
|
339
|
+
if (currentEntity) {
|
|
340
|
+
this.emitEntity(currentEntity, words, fullText, entities);
|
|
341
|
+
}
|
|
342
|
+
currentEntity = {
|
|
343
|
+
entityName: entityName,
|
|
344
|
+
entityType: LABEL_TO_ENTITY_TYPE[entityName] || 'OTHER',
|
|
345
|
+
startWord: wi,
|
|
346
|
+
endWord: wi,
|
|
347
|
+
confidence: pred.confidence,
|
|
348
|
+
tokenCount: 1,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
else if (isI && currentEntity && entityName === currentEntity.entityName) {
|
|
352
|
+
currentEntity.endWord = wi;
|
|
353
|
+
currentEntity.confidence += pred.confidence;
|
|
354
|
+
currentEntity.tokenCount += 1;
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
if (currentEntity) {
|
|
358
|
+
this.emitEntity(currentEntity, words, fullText, entities);
|
|
359
|
+
currentEntity = null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (currentEntity) {
|
|
364
|
+
this.emitEntity(currentEntity, words, fullText, entities);
|
|
365
|
+
}
|
|
366
|
+
return entities;
|
|
367
|
+
}
|
|
368
|
+
emitEntity(entity, words, fullText, entities) {
|
|
369
|
+
const avgConfidence = entity.confidence / entity.tokenCount;
|
|
370
|
+
if (avgConfidence < this.threshold)
|
|
371
|
+
return;
|
|
372
|
+
const charStart = words[entity.startWord].start;
|
|
373
|
+
const charEnd = words[entity.endWord].end;
|
|
374
|
+
entities.push({
|
|
375
|
+
type: entity.entityType,
|
|
376
|
+
value: fullText.slice(charStart, charEnd),
|
|
377
|
+
start: charStart,
|
|
378
|
+
end: charEnd,
|
|
379
|
+
confidence: avgConfidence,
|
|
380
|
+
detector: `bardsai:${entity.entityName}`,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
deduplicateSpans(entities) {
|
|
384
|
+
const sorted = [...entities].sort((a, b) => b.confidence - a.confidence);
|
|
385
|
+
const result = [];
|
|
386
|
+
for (const e of sorted) {
|
|
387
|
+
const overlaps = result.some((existing) => e.start < existing.end && e.end > existing.start);
|
|
388
|
+
if (!overlaps)
|
|
389
|
+
result.push(e);
|
|
390
|
+
}
|
|
391
|
+
return result;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GLiNER PII Edge provider.
|
|
3
|
+
*
|
|
4
|
+
* Environment-agnostic: wasm paths, custom-label persistence (KV), model
|
|
5
|
+
* bytes (blob cache + fetch) and the tokenizer all arrive through the
|
|
6
|
+
* injected CoreEnv. This module never touches import.meta, localStorage,
|
|
7
|
+
* caches, navigator or @huggingface/transformers directly.
|
|
8
|
+
*/
|
|
9
|
+
import type { DetectedEntity, DetectionProvider, ProgressCallback } from '../types.js';
|
|
10
|
+
import type { CoreEnv } from '../env.js';
|
|
11
|
+
export declare class GlinerProvider implements DetectionProvider {
|
|
12
|
+
private _name;
|
|
13
|
+
get name(): string;
|
|
14
|
+
private env;
|
|
15
|
+
private tokenizer;
|
|
16
|
+
private session;
|
|
17
|
+
private loading;
|
|
18
|
+
private loadError;
|
|
19
|
+
private loadWaiters;
|
|
20
|
+
private progressCallback;
|
|
21
|
+
private threshold;
|
|
22
|
+
private customLabels;
|
|
23
|
+
constructor(env: CoreEnv);
|
|
24
|
+
isLoaded(): boolean;
|
|
25
|
+
isLoading(): boolean;
|
|
26
|
+
onProgress(callback: ProgressCallback): void;
|
|
27
|
+
setThreshold(value: number): void;
|
|
28
|
+
getThreshold(): number;
|
|
29
|
+
/**
|
|
30
|
+
* Get the combined list of all active labels (built-in + custom).
|
|
31
|
+
*/
|
|
32
|
+
private getActiveLabels;
|
|
33
|
+
/**
|
|
34
|
+
* Get user-defined custom labels.
|
|
35
|
+
*/
|
|
36
|
+
getCustomLabels(): string[];
|
|
37
|
+
/**
|
|
38
|
+
* Set custom labels and persist through the injected KV store. The
|
|
39
|
+
* in-memory list updates synchronously (before the first await), so
|
|
40
|
+
* fire-and-forget callers keep the previous behavior.
|
|
41
|
+
*/
|
|
42
|
+
setCustomLabels(labels: string[]): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Restore custom labels from the injected KV store on startup.
|
|
45
|
+
*/
|
|
46
|
+
restoreCustomLabels(): Promise<void>;
|
|
47
|
+
load(): Promise<void>;
|
|
48
|
+
release(): void;
|
|
49
|
+
detect(text: string, onProgress?: (progress: number) => void): Promise<DetectedEntity[]>;
|
|
50
|
+
private buildPromptTokens;
|
|
51
|
+
private inferChunk;
|
|
52
|
+
}
|