@doccloak/core 0.9.0 → 0.10.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 +43 -0
- package/dist/dom/docx.d.ts +29 -2
- package/dist/dom/docx.js +64 -10
- package/dist/dom/index.d.ts +2 -0
- package/dist/dom/index.js +3 -0
- package/dist/dom/office.d.ts +75 -0
- package/dist/dom/office.js +116 -0
- package/dist/dom/opc.d.ts +42 -0
- package/dist/dom/opc.js +146 -0
- package/dist/dom/xlsx.d.ts +64 -0
- package/dist/dom/xlsx.js +280 -0
- package/dist/engine.d.ts +8 -5
- package/dist/engine.js +21 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/providers/bardsai.js +16 -16
- package/dist/providers/gliner-base.d.ts +92 -0
- package/dist/providers/gliner-base.js +357 -0
- package/dist/providers/gliner.d.ts +22 -6
- package/dist/providers/gliner.js +43 -20
- package/package.json +3 -2
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GLiNER PII Base provider (T122).
|
|
3
|
+
*
|
|
4
|
+
* knowledgator/gliner-pii-base-v1.0: deberta-v3-small backbone, zero-shot
|
|
5
|
+
* custom labels, span_mode "markerV0". Unlike the token_level small/edge
|
|
6
|
+
* models (gliner.ts), the ONNX graph takes span enumeration inputs
|
|
7
|
+
* (span_idx + span_mask) and emits span-level logits shaped
|
|
8
|
+
* [batch, num_words, max_width, num_classes] - one score per candidate
|
|
9
|
+
* (start word, width) pair per label. Verified against the pinned model in
|
|
10
|
+
* Node WASM: inputs [input_ids, attention_mask, words_mask, text_lengths,
|
|
11
|
+
* span_idx (int64), span_mask (bool)], output [logits].
|
|
12
|
+
*
|
|
13
|
+
* Environment-agnostic: wasm paths, custom-label persistence (KV), model
|
|
14
|
+
* bytes (blob cache + fetch) and the tokenizer all arrive through the
|
|
15
|
+
* injected CoreEnv. This module never touches import.meta, localStorage,
|
|
16
|
+
* caches, navigator or @huggingface/transformers directly.
|
|
17
|
+
*/
|
|
18
|
+
// ort 1.29: the /webgpu entry is the non-deprecated build (native WebGPU EP +
|
|
19
|
+
// wasm fallback, asyncify binary). The bare entry resolves the deprecated JSEP
|
|
20
|
+
// bundle whose wasm exceeds Cloudflare Pages' 25 MiB per-file limit.
|
|
21
|
+
import * as ort from 'onnxruntime-web/webgpu';
|
|
22
|
+
import { fetchModelBlob, retryAsync } from "../model-loader.js";
|
|
23
|
+
import { DEFAULT_PII_LABELS, greedySelect, mapLabelToEntityType, sigmoid, splitWords, } from "./gliner.js";
|
|
24
|
+
// ── Model config ──────────────────────────────────────────
|
|
25
|
+
// Supply-chain pinning (T116): the model is fetched from an immutable
|
|
26
|
+
// commit revision, never from mutable resolve/main, and the downloaded
|
|
27
|
+
// blob is verified against a pinned SHA-256. A model update is a
|
|
28
|
+
// deliberate act: bump GLINER_BASE_MODEL_REVISION and
|
|
29
|
+
// GLINER_BASE_MODEL_SHA256 together in a reviewed commit and update
|
|
30
|
+
// documentation/model-provenance.md.
|
|
31
|
+
// Revision: main of knowledgator/gliner-pii-base-v1.0; pinned and hashed
|
|
32
|
+
// 2026-08-26 (T122).
|
|
33
|
+
export const GLINER_BASE_MODEL_REVISION = '61726e0ad791dcab3e29339bbec3ad42ded65641';
|
|
34
|
+
/** SHA-256 of onnx/model_quint8.onnx (196,757,174 bytes) at GLINER_BASE_MODEL_REVISION. */
|
|
35
|
+
export const GLINER_BASE_MODEL_SHA256 = '0514c8fd86d0513ce5351a3267f132b57d5bcd8f99a90d43cde1228092881d19';
|
|
36
|
+
export const GLINER_BASE_MODEL_URL = `https://huggingface.co/knowledgator/gliner-pii-base-v1.0/resolve/${GLINER_BASE_MODEL_REVISION}/onnx/model_quint8.onnx`;
|
|
37
|
+
const DEFAULT_MODEL_URL = GLINER_BASE_MODEL_URL;
|
|
38
|
+
const DEFAULT_TOKENIZER_HF = 'knowledgator/gliner-pii-base-v1.0';
|
|
39
|
+
const DEFAULT_MODEL_NAME = 'GLiNER PII Base';
|
|
40
|
+
const CUSTOM_LABELS_STORAGE_KEY = 'doccloak-custom-labels';
|
|
41
|
+
// Special token ids of the deberta-v3-small vocab (vocab_size 128003).
|
|
42
|
+
// Verified against the repo's tokenizer_config.json at the pinned
|
|
43
|
+
// revision - these differ from the ettin ids used by gliner.ts.
|
|
44
|
+
const CLS_TOKEN_ID = 1; // [CLS]
|
|
45
|
+
const SEP_TOKEN_ID = 2; // [SEP]
|
|
46
|
+
const ENT_TOKEN_ID = 128001; // <<ENT>> (class_token_index in gliner_config.json)
|
|
47
|
+
const SEP_PROMPT_TOKEN_ID = 128002; // <<SEP>> (prompt separator, not [SEP])
|
|
48
|
+
/** max_width from the repo's gliner_config.json: spans cover 1..12 words. */
|
|
49
|
+
export const GLINER_BASE_MAX_WIDTH = 12;
|
|
50
|
+
const DEFAULT_THRESHOLD = 0.35;
|
|
51
|
+
const MAX_WORDS_PER_CHUNK = 150;
|
|
52
|
+
const CHUNK_OVERLAP = 40;
|
|
53
|
+
/**
|
|
54
|
+
* Enumerate all candidate word spans exactly like the python GLiNER
|
|
55
|
+
* SpanProcessor: start-major, width-minor - for every start word i, the
|
|
56
|
+
* pairs (i, i+0) .. (i, i+maxWidth-1). Spans that would run past the end
|
|
57
|
+
* of the text are masked out and their indices zeroed (mirroring
|
|
58
|
+
* span_idx * span_mask in the python collate).
|
|
59
|
+
*/
|
|
60
|
+
export function buildSpanIndices(textLength, maxWidth = GLINER_BASE_MAX_WIDTH) {
|
|
61
|
+
const numSpans = textLength * maxWidth;
|
|
62
|
+
const spanIdx = new BigInt64Array(numSpans * 2);
|
|
63
|
+
const spanMask = new Uint8Array(numSpans);
|
|
64
|
+
for (let start = 0; start < textLength; start++) {
|
|
65
|
+
for (let width = 0; width < maxWidth; width++) {
|
|
66
|
+
const flat = start * maxWidth + width;
|
|
67
|
+
const end = start + width;
|
|
68
|
+
if (end < textLength) {
|
|
69
|
+
spanIdx[flat * 2] = BigInt(start);
|
|
70
|
+
spanIdx[flat * 2 + 1] = BigInt(end);
|
|
71
|
+
spanMask[flat] = 1;
|
|
72
|
+
}
|
|
73
|
+
// invalid spans stay (0, 0) with mask 0
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { spanIdx, spanMask, numSpans };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Decode markerV0 span logits (shape [1, numWords, maxWidth, numClasses])
|
|
80
|
+
* into raw character spans: sigmoid per (start, width, class) cell,
|
|
81
|
+
* threshold, then map word indices to character offsets. Non-overlap
|
|
82
|
+
* selection (greedySelect) happens at the caller across chunks.
|
|
83
|
+
*/
|
|
84
|
+
export function decodeSpanLogits(logits, dims, textLength, labels, threshold, charStarts, charEnds, fullText) {
|
|
85
|
+
const maxWidth = dims[2];
|
|
86
|
+
const numClasses = dims[3];
|
|
87
|
+
const spans = [];
|
|
88
|
+
for (let start = 0; start < textLength; start++) {
|
|
89
|
+
for (let width = 0; width < maxWidth; width++) {
|
|
90
|
+
const end = start + width;
|
|
91
|
+
if (end >= textLength)
|
|
92
|
+
break;
|
|
93
|
+
const base = (start * maxWidth + width) * numClasses;
|
|
94
|
+
for (let cls = 0; cls < numClasses && cls < labels.length; cls++) {
|
|
95
|
+
const prob = sigmoid(logits[base + cls]);
|
|
96
|
+
if (prob < threshold)
|
|
97
|
+
continue;
|
|
98
|
+
const charStart = charStarts[start];
|
|
99
|
+
const charEnd = charEnds[end];
|
|
100
|
+
spans.push([fullText.slice(charStart, charEnd), charStart, charEnd, labels[cls], prob]);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return spans;
|
|
105
|
+
}
|
|
106
|
+
// ── Provider ──────────────────────────────────────────────
|
|
107
|
+
export class GlinerBaseProvider {
|
|
108
|
+
get name() { return this._name; }
|
|
109
|
+
constructor(env) {
|
|
110
|
+
this._name = DEFAULT_MODEL_NAME;
|
|
111
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
112
|
+
this.tokenizer = null;
|
|
113
|
+
this.session = null;
|
|
114
|
+
this.loading = false;
|
|
115
|
+
this.loadError = null;
|
|
116
|
+
this.loadWaiters = [];
|
|
117
|
+
this.progressCallback = null;
|
|
118
|
+
this.threshold = DEFAULT_THRESHOLD;
|
|
119
|
+
this.customLabels = [];
|
|
120
|
+
this.verification = null;
|
|
121
|
+
this.env = env;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* SHA-256 verification result of the served model blob (null until the
|
|
125
|
+
* model has been verified against the pinned hash). Surfaced so the
|
|
126
|
+
* model-status UI can show the positive "Model verified" state.
|
|
127
|
+
*/
|
|
128
|
+
getVerification() {
|
|
129
|
+
return this.verification;
|
|
130
|
+
}
|
|
131
|
+
isLoaded() {
|
|
132
|
+
return this.session !== null && this.tokenizer !== null;
|
|
133
|
+
}
|
|
134
|
+
isLoading() {
|
|
135
|
+
return this.loading;
|
|
136
|
+
}
|
|
137
|
+
onProgress(callback) {
|
|
138
|
+
this.progressCallback = callback;
|
|
139
|
+
}
|
|
140
|
+
setThreshold(value) {
|
|
141
|
+
this.threshold = Math.max(0.05, Math.min(0.95, value));
|
|
142
|
+
}
|
|
143
|
+
getThreshold() {
|
|
144
|
+
return this.threshold;
|
|
145
|
+
}
|
|
146
|
+
/** Get the combined list of all active labels (built-in + custom). */
|
|
147
|
+
getActiveLabels() {
|
|
148
|
+
return [...DEFAULT_PII_LABELS, ...this.customLabels];
|
|
149
|
+
}
|
|
150
|
+
/** Get user-defined custom labels. */
|
|
151
|
+
getCustomLabels() {
|
|
152
|
+
return [...this.customLabels];
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Set custom labels and persist through the injected KV store. The
|
|
156
|
+
* in-memory list updates synchronously (before the first await), so
|
|
157
|
+
* fire-and-forget callers keep the previous behavior.
|
|
158
|
+
*/
|
|
159
|
+
async setCustomLabels(labels) {
|
|
160
|
+
this.customLabels = labels.filter((l) => l.trim().length > 0);
|
|
161
|
+
try {
|
|
162
|
+
await this.env.kv.set(CUSTOM_LABELS_STORAGE_KEY, JSON.stringify(this.customLabels));
|
|
163
|
+
}
|
|
164
|
+
catch { /* KV backend unavailable (e.g. storage-less Web Worker) */ }
|
|
165
|
+
}
|
|
166
|
+
/** Restore custom labels from the injected KV store on startup. */
|
|
167
|
+
async restoreCustomLabels() {
|
|
168
|
+
try {
|
|
169
|
+
const saved = await this.env.kv.get(CUSTOM_LABELS_STORAGE_KEY);
|
|
170
|
+
if (saved) {
|
|
171
|
+
this.customLabels = JSON.parse(saved);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch { /* KV backend unavailable in Web Worker - labels are passed via message */ }
|
|
175
|
+
}
|
|
176
|
+
async load() {
|
|
177
|
+
if (this.isLoaded())
|
|
178
|
+
return;
|
|
179
|
+
if (this.loadError)
|
|
180
|
+
throw this.loadError;
|
|
181
|
+
if (this.loading) {
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
this.loadWaiters.push({ resolve, reject });
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
this.loading = true;
|
|
187
|
+
try {
|
|
188
|
+
ort.env.wasm.wasmPaths = this.env.wasm.paths;
|
|
189
|
+
// Default to single-threaded WASM. ORT's multi-threaded path hangs
|
|
190
|
+
// silently in some cross-origin-isolated browser contexts when spawning
|
|
191
|
+
// pthread workers (model downloads but session.create never resolves).
|
|
192
|
+
ort.env.wasm.numThreads = this.env.wasm.numThreads ?? 1;
|
|
193
|
+
const loaderEnv = {
|
|
194
|
+
cache: this.env.modelCache,
|
|
195
|
+
fetch: this.env.fetch,
|
|
196
|
+
persistStorage: this.env.persistStorage,
|
|
197
|
+
};
|
|
198
|
+
// Cache note (T122): the BardS.ai blob is deliberately NOT evicted -
|
|
199
|
+
// bardsai stays selectable as a legacy provider.
|
|
200
|
+
// Load tokenizer and model in parallel (skip tokenizer if already loaded from a prior session)
|
|
201
|
+
const tasks = [
|
|
202
|
+
fetchModelBlob(loaderEnv, DEFAULT_MODEL_URL, (downloaded, total) => this.progressCallback?.(downloaded, total), {
|
|
203
|
+
sha256: GLINER_BASE_MODEL_SHA256,
|
|
204
|
+
onVerified: (verification) => {
|
|
205
|
+
this.verification = verification;
|
|
206
|
+
console.info(`[DocCloak] Model verified: ${this._name} SHA-256 ${verification.sha256.slice(0, 12)}... matches the published hash (see documentation/model-provenance.md)`);
|
|
207
|
+
},
|
|
208
|
+
}),
|
|
209
|
+
];
|
|
210
|
+
if (!this.tokenizer) {
|
|
211
|
+
tasks.push(retryAsync(() => this.env.loadTokenizer(DEFAULT_TOKENIZER_HF), 'Tokenizer download').then((t) => {
|
|
212
|
+
this.tokenizer = t;
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
const [modelBlob] = await Promise.all(tasks);
|
|
216
|
+
const blobUrl = URL.createObjectURL(modelBlob);
|
|
217
|
+
try {
|
|
218
|
+
this.session = await ort.InferenceSession.create(blobUrl, {
|
|
219
|
+
executionProviders: ['wasm'],
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
// ONNX Runtime has already read the data into the WASM heap
|
|
224
|
+
URL.revokeObjectURL(blobUrl);
|
|
225
|
+
}
|
|
226
|
+
console.info(`[DocCloak] Model loaded: ${this._name} | inputs: [${this.session.inputNames.join(', ')}] | outputs: [${this.session.outputNames.join(', ')}]`);
|
|
227
|
+
this.loading = false;
|
|
228
|
+
this.loadWaiters.forEach((w) => w.resolve());
|
|
229
|
+
this.loadWaiters.length = 0;
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
this.session = null;
|
|
233
|
+
this.loadError = err instanceof Error ? err : new Error(String(err));
|
|
234
|
+
this.loading = false;
|
|
235
|
+
this.loadWaiters.forEach((w) => w.reject(this.loadError));
|
|
236
|
+
this.loadWaiters.length = 0;
|
|
237
|
+
throw this.loadError;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
release() {
|
|
241
|
+
if (this.session) {
|
|
242
|
+
this.session.release();
|
|
243
|
+
this.session = null;
|
|
244
|
+
}
|
|
245
|
+
this.loading = false;
|
|
246
|
+
this.loadError = null;
|
|
247
|
+
}
|
|
248
|
+
async detect(text, onProgress) {
|
|
249
|
+
if (!this.isLoaded())
|
|
250
|
+
await this.load();
|
|
251
|
+
if (!this.session || !this.tokenizer)
|
|
252
|
+
return [];
|
|
253
|
+
const { words, starts, ends } = splitWords(text);
|
|
254
|
+
if (words.length === 0)
|
|
255
|
+
return [];
|
|
256
|
+
// Build prompt tokens once (shared across all chunks)
|
|
257
|
+
const activeLabels = this.getActiveLabels();
|
|
258
|
+
const promptTokens = this.buildPromptTokens(activeLabels);
|
|
259
|
+
const chunkSize = MAX_WORDS_PER_CHUNK;
|
|
260
|
+
const overlap = CHUNK_OVERLAP;
|
|
261
|
+
// Split into chunks if text is too long
|
|
262
|
+
const allSpans = [];
|
|
263
|
+
onProgress?.(0);
|
|
264
|
+
if (words.length <= chunkSize) {
|
|
265
|
+
// Single chunk - no splitting needed
|
|
266
|
+
const spans = await this.inferChunk(words, starts, ends, text, promptTokens, activeLabels);
|
|
267
|
+
allSpans.push(...spans);
|
|
268
|
+
onProgress?.(1);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
// Count total chunks
|
|
272
|
+
let totalChunks = 0;
|
|
273
|
+
for (let cs = 0; cs < words.length; cs += chunkSize - overlap) {
|
|
274
|
+
totalChunks++;
|
|
275
|
+
if (Math.min(cs + chunkSize, words.length) >= words.length)
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
// Multiple overlapping chunks
|
|
279
|
+
let chunksDone = 0;
|
|
280
|
+
for (let chunkStart = 0; chunkStart < words.length; chunkStart += chunkSize - overlap) {
|
|
281
|
+
const chunkEnd = Math.min(chunkStart + chunkSize, words.length);
|
|
282
|
+
const chunkWords = words.slice(chunkStart, chunkEnd);
|
|
283
|
+
const chunkStarts = starts.slice(chunkStart, chunkEnd);
|
|
284
|
+
const chunkEnds = ends.slice(chunkStart, chunkEnd);
|
|
285
|
+
const spans = await this.inferChunk(chunkWords, chunkStarts, chunkEnds, text, promptTokens, activeLabels);
|
|
286
|
+
allSpans.push(...spans);
|
|
287
|
+
chunksDone++;
|
|
288
|
+
onProgress?.(chunksDone / totalChunks);
|
|
289
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
290
|
+
if (chunkEnd >= words.length)
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// Greedy non-overlapping selection across all chunks
|
|
295
|
+
const selected = greedySelect(allSpans);
|
|
296
|
+
return selected
|
|
297
|
+
.filter(([spanText]) => spanText.trim().length >= 2)
|
|
298
|
+
.map(([spanText, start, end, label, score]) => ({
|
|
299
|
+
type: mapLabelToEntityType(label),
|
|
300
|
+
value: spanText,
|
|
301
|
+
start,
|
|
302
|
+
end,
|
|
303
|
+
confidence: score,
|
|
304
|
+
detector: `gliner-base:${label}`,
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
buildPromptTokens(labels) {
|
|
308
|
+
const tokens = [];
|
|
309
|
+
for (const label of labels) {
|
|
310
|
+
tokens.push(ENT_TOKEN_ID);
|
|
311
|
+
for (const part of label.split(' ')) {
|
|
312
|
+
const subTokens = this.tokenizer.encode(part).slice(1, -1);
|
|
313
|
+
tokens.push(...subTokens);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
tokens.push(SEP_PROMPT_TOKEN_ID);
|
|
317
|
+
return tokens;
|
|
318
|
+
}
|
|
319
|
+
async inferChunk(words, charStarts, charEnds, fullText, promptTokens, labels) {
|
|
320
|
+
if (!this.session)
|
|
321
|
+
return [];
|
|
322
|
+
// Build token sequence: [CLS] + prompt + text words + [SEP]
|
|
323
|
+
const inputIds = [CLS_TOKEN_ID, ...promptTokens];
|
|
324
|
+
const attentionMask = new Array(inputIds.length).fill(1);
|
|
325
|
+
const wordsMask = new Array(inputIds.length).fill(0);
|
|
326
|
+
let wordCounter = 1;
|
|
327
|
+
for (const word of words) {
|
|
328
|
+
const subTokens = this.tokenizer.encode(word).slice(1, -1);
|
|
329
|
+
for (let j = 0; j < subTokens.length; j++) {
|
|
330
|
+
inputIds.push(subTokens[j]);
|
|
331
|
+
attentionMask.push(1);
|
|
332
|
+
wordsMask.push(j === 0 ? wordCounter : 0);
|
|
333
|
+
}
|
|
334
|
+
wordCounter++;
|
|
335
|
+
}
|
|
336
|
+
inputIds.push(SEP_TOKEN_ID);
|
|
337
|
+
attentionMask.push(1);
|
|
338
|
+
wordsMask.push(0);
|
|
339
|
+
const seqLen = inputIds.length;
|
|
340
|
+
const textLength = words.length;
|
|
341
|
+
const { spanIdx, spanMask, numSpans } = buildSpanIndices(textLength);
|
|
342
|
+
const feeds = {
|
|
343
|
+
input_ids: new ort.Tensor('int64', BigInt64Array.from(inputIds.map(BigInt)), [1, seqLen]),
|
|
344
|
+
attention_mask: new ort.Tensor('int64', BigInt64Array.from(attentionMask.map(BigInt)), [1, seqLen]),
|
|
345
|
+
words_mask: new ort.Tensor('int64', BigInt64Array.from(wordsMask.map(BigInt)), [1, seqLen]),
|
|
346
|
+
text_lengths: new ort.Tensor('int64', BigInt64Array.from([BigInt(textLength)]), [1, 1]),
|
|
347
|
+
span_idx: new ort.Tensor('int64', spanIdx, [1, numSpans, 2]),
|
|
348
|
+
span_mask: new ort.Tensor('bool', spanMask, [1, numSpans]),
|
|
349
|
+
};
|
|
350
|
+
const results = await this.session.run(feeds);
|
|
351
|
+
const outputName = this.session.outputNames[0] || 'logits';
|
|
352
|
+
const output = results[outputName];
|
|
353
|
+
const logits = output.data;
|
|
354
|
+
// markerV0: logits shape [1, num_words, max_width, num_classes]
|
|
355
|
+
return decodeSpanLogits(logits, output.dims, textLength, labels, this.threshold, charStarts, charEnds, fullText);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -1,18 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GLiNER PII
|
|
2
|
+
* GLiNER PII Small provider.
|
|
3
3
|
*
|
|
4
4
|
* Environment-agnostic: wasm paths, custom-label persistence (KV), model
|
|
5
5
|
* bytes (blob cache + fetch) and the tokenizer all arrive through the
|
|
6
6
|
* injected CoreEnv. This module never touches import.meta, localStorage,
|
|
7
7
|
* caches, navigator or @huggingface/transformers directly.
|
|
8
8
|
*/
|
|
9
|
-
import type { DetectedEntity, DetectionProvider, ProgressCallback } from '../types.js';
|
|
9
|
+
import type { DetectedEntity, EntityType, DetectionProvider, ProgressCallback } from '../types.js';
|
|
10
10
|
import type { CoreEnv } from '../env.js';
|
|
11
11
|
import type { ModelVerification } from '../model-loader.js';
|
|
12
|
-
export declare const GLINER_MODEL_REVISION = "
|
|
13
|
-
/** SHA-256 of onnx/model_quint8.onnx (
|
|
14
|
-
export declare const GLINER_MODEL_SHA256 = "
|
|
15
|
-
export declare const GLINER_MODEL_URL = "https://huggingface.co/knowledgator/gliner-pii-
|
|
12
|
+
export declare const GLINER_MODEL_REVISION = "d21aad5b4a7ec82b3d0970fd1ac74a12c087d85e";
|
|
13
|
+
/** SHA-256 of onnx/model_quint8.onnx (82,680,500 bytes) at GLINER_MODEL_REVISION. */
|
|
14
|
+
export declare const GLINER_MODEL_SHA256 = "891589426ee96f2748b16439f44fad8c3f97e198e002a6637e58dee989500216";
|
|
15
|
+
export declare const GLINER_MODEL_URL = "https://huggingface.co/knowledgator/gliner-pii-small-v1.0/resolve/d21aad5b4a7ec82b3d0970fd1ac74a12c087d85e/onnx/model_quint8.onnx";
|
|
16
|
+
/**
|
|
17
|
+
* Built-in zero-shot label set, shared with the GLiNER PII Base provider
|
|
18
|
+
* (gliner-base.ts) - the models are from the same zero-shot family, so the
|
|
19
|
+
* same prompt labels work for both.
|
|
20
|
+
*/
|
|
21
|
+
export declare const DEFAULT_PII_LABELS: string[];
|
|
22
|
+
/** Shared with gliner-base.ts (same label set, same mapping). */
|
|
23
|
+
export declare function mapLabelToEntityType(label: string): EntityType;
|
|
24
|
+
export declare function splitWords(text: string): {
|
|
25
|
+
words: string[];
|
|
26
|
+
starts: number[];
|
|
27
|
+
ends: number[];
|
|
28
|
+
};
|
|
29
|
+
export declare function sigmoid(x: number): number;
|
|
30
|
+
export type RawSpan = [string, number, number, string, number];
|
|
31
|
+
export declare function greedySelect(spans: RawSpan[]): RawSpan[];
|
|
16
32
|
export declare class GlinerProvider implements DetectionProvider {
|
|
17
33
|
private _name;
|
|
18
34
|
get name(): string;
|
package/dist/providers/gliner.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GLiNER PII
|
|
2
|
+
* GLiNER PII Small provider.
|
|
3
3
|
*
|
|
4
4
|
* Environment-agnostic: wasm paths, custom-label persistence (KV), model
|
|
5
5
|
* bytes (blob cache + fetch) and the tokenizer all arrive through the
|
|
6
6
|
* injected CoreEnv. This module never touches import.meta, localStorage,
|
|
7
7
|
* caches, navigator or @huggingface/transformers directly.
|
|
8
8
|
*/
|
|
9
|
-
|
|
9
|
+
// ort 1.29: the /webgpu entry is the non-deprecated build (native WebGPU EP +
|
|
10
|
+
// wasm fallback, asyncify binary). The bare entry resolves the deprecated JSEP
|
|
11
|
+
// bundle whose wasm exceeds Cloudflare Pages' 25 MiB per-file limit.
|
|
12
|
+
import * as ort from 'onnxruntime-web/webgpu';
|
|
10
13
|
import { evictModelFromCache, fetchModelBlob, retryAsync } from "../model-loader.js";
|
|
11
14
|
// ── Model config ──────────────────────────────────────────
|
|
12
15
|
// Supply-chain pinning (T116): the model is fetched from an immutable
|
|
@@ -15,17 +18,26 @@ import { evictModelFromCache, fetchModelBlob, retryAsync } from "../model-loader
|
|
|
15
18
|
// deliberate act: bump GLINER_MODEL_REVISION and GLINER_MODEL_SHA256
|
|
16
19
|
// together in a reviewed commit and update
|
|
17
20
|
// documentation/model-provenance.md.
|
|
18
|
-
// Revision: main of knowledgator/gliner-pii-
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
export const
|
|
21
|
+
// Revision: main of knowledgator/gliner-pii-small-v1.0; pinned and
|
|
22
|
+
// hashed 2026-08-26 (T121 swap from gliner-pii-edge-v1.0, +1.3 F1
|
|
23
|
+
// drop-in per documentation/model-research-2026-08.md).
|
|
24
|
+
export const GLINER_MODEL_REVISION = 'd21aad5b4a7ec82b3d0970fd1ac74a12c087d85e';
|
|
25
|
+
/** SHA-256 of onnx/model_quint8.onnx (82,680,500 bytes) at GLINER_MODEL_REVISION. */
|
|
26
|
+
export const GLINER_MODEL_SHA256 = '891589426ee96f2748b16439f44fad8c3f97e198e002a6637e58dee989500216';
|
|
27
|
+
export const GLINER_MODEL_URL = `https://huggingface.co/knowledgator/gliner-pii-small-v1.0/resolve/${GLINER_MODEL_REVISION}/onnx/model_quint8.onnx`;
|
|
24
28
|
const DEFAULT_MODEL_URL = GLINER_MODEL_URL;
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Superseded download URLs; their cache entries are evicted best-effort on
|
|
31
|
+
* load so stale ~46 MB copies do not linger against the origin quota:
|
|
32
|
+
* the pre-pinning edge resolve/main URL and the pinned edge URL replaced
|
|
33
|
+
* by gliner-pii-small-v1.0 (T121).
|
|
34
|
+
*/
|
|
35
|
+
const LEGACY_MODEL_URLS = [
|
|
36
|
+
'https://huggingface.co/knowledgator/gliner-pii-edge-v1.0/resolve/main/onnx/model_quint8.onnx',
|
|
37
|
+
'https://huggingface.co/knowledgator/gliner-pii-edge-v1.0/resolve/9b7f39b0a2da971a5beea78d35f1539d4009c891/onnx/model_quint8.onnx',
|
|
38
|
+
];
|
|
39
|
+
const DEFAULT_TOKENIZER_HF = 'knowledgator/gliner-pii-small-v1.0';
|
|
40
|
+
const DEFAULT_MODEL_NAME = 'GLiNER PII Small';
|
|
29
41
|
const CUSTOM_LABELS_STORAGE_KEY = 'doccloak-custom-labels';
|
|
30
42
|
const CLS_TOKEN_ID = 50281;
|
|
31
43
|
const SEP_TOKEN_ID = 50282;
|
|
@@ -34,7 +46,12 @@ const SEP_PROMPT_TOKEN_ID = 50369; // <<SEP>> (prompt separator, not [SEP])
|
|
|
34
46
|
const DEFAULT_THRESHOLD = 0.35;
|
|
35
47
|
const MAX_WORDS_PER_CHUNK = 150;
|
|
36
48
|
const CHUNK_OVERLAP = 40;
|
|
37
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Built-in zero-shot label set, shared with the GLiNER PII Base provider
|
|
51
|
+
* (gliner-base.ts) - the models are from the same zero-shot family, so the
|
|
52
|
+
* same prompt labels work for both.
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_PII_LABELS = [
|
|
38
55
|
'person name',
|
|
39
56
|
'calendar date',
|
|
40
57
|
'email address', 'phone number', 'ip address',
|
|
@@ -46,7 +63,8 @@ const DEFAULT_PII_LABELS = [
|
|
|
46
63
|
'company name',
|
|
47
64
|
];
|
|
48
65
|
// ── Label → EntityType mapping ────────────────────────────
|
|
49
|
-
|
|
66
|
+
/** Shared with gliner-base.ts (same label set, same mapping). */
|
|
67
|
+
export function mapLabelToEntityType(label) {
|
|
50
68
|
const l = label.toLowerCase();
|
|
51
69
|
if (l === 'person name')
|
|
52
70
|
return 'PERSON';
|
|
@@ -73,8 +91,10 @@ function mapLabelToEntityType(label) {
|
|
|
73
91
|
return 'OTHER';
|
|
74
92
|
}
|
|
75
93
|
// ── Word splitter ─────────────────────────────────────────
|
|
94
|
+
// Mirrors GLiNER's WhitespaceTokenSplitter (words, plus standalone
|
|
95
|
+
// punctuation); shared with gliner-base.ts (same words_splitter_type).
|
|
76
96
|
const WORD_PATTERN = /[\p{L}\p{N}]+(?:[-_][\p{L}\p{N}]+)*|\S/gu;
|
|
77
|
-
function splitWords(text) {
|
|
97
|
+
export function splitWords(text) {
|
|
78
98
|
const words = [];
|
|
79
99
|
const starts = [];
|
|
80
100
|
const ends = [];
|
|
@@ -88,10 +108,10 @@ function splitWords(text) {
|
|
|
88
108
|
return { words, starts, ends };
|
|
89
109
|
}
|
|
90
110
|
// ── Sigmoid ───────────────────────────────────────────────
|
|
91
|
-
function sigmoid(x) {
|
|
111
|
+
export function sigmoid(x) {
|
|
92
112
|
return 1 / (1 + Math.exp(-x));
|
|
93
113
|
}
|
|
94
|
-
function greedySelect(spans) {
|
|
114
|
+
export function greedySelect(spans) {
|
|
95
115
|
const sorted = spans.slice().sort((a, b) => b[4] - a[4]);
|
|
96
116
|
const selected = [];
|
|
97
117
|
for (const span of sorted) {
|
|
@@ -199,9 +219,12 @@ export class GlinerProvider {
|
|
|
199
219
|
fetch: this.env.fetch,
|
|
200
220
|
persistStorage: this.env.persistStorage,
|
|
201
221
|
};
|
|
202
|
-
// Old cache
|
|
203
|
-
//
|
|
204
|
-
|
|
222
|
+
// Old cache entries from superseded URLs (pre-pinning resolve/main
|
|
223
|
+
// and the retired edge pin) - drop them so stale copies do not
|
|
224
|
+
// linger against the origin quota.
|
|
225
|
+
for (const legacyUrl of LEGACY_MODEL_URLS) {
|
|
226
|
+
void evictModelFromCache(loaderEnv, legacyUrl);
|
|
227
|
+
}
|
|
205
228
|
// Load tokenizer and model in parallel (skip tokenizer if already loaded from a prior session)
|
|
206
229
|
const tasks = [
|
|
207
230
|
fetchModelBlob(loaderEnv, DEFAULT_MODEL_URL, (downloaded, total) => this.progressCallback?.(downloaded, total), {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doccloak/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Environment-agnostic document anonymization engine for the DocCloak ecosystem.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Witold Lojek",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"rules:gen": "node scripts/generate-rules-module.mjs",
|
|
63
63
|
"rules:check": "node scripts/generate-rules-module.mjs --check",
|
|
64
64
|
"bench": "node --experimental-strip-types eval/run.mts",
|
|
65
|
+
"bench:multilang": "node --experimental-strip-types eval/multilang.mts",
|
|
65
66
|
"bench:data": "node eval/download.mjs",
|
|
66
67
|
"test": "vitest run",
|
|
67
68
|
"test:watch": "vitest",
|
|
@@ -75,7 +76,7 @@
|
|
|
75
76
|
"dependencies": {
|
|
76
77
|
"cfb": "1.2.2",
|
|
77
78
|
"jszip": "3.10.1",
|
|
78
|
-
"onnxruntime-web": "1.
|
|
79
|
+
"onnxruntime-web": "1.29.0",
|
|
79
80
|
"tesseract.js": "7.0.0"
|
|
80
81
|
}
|
|
81
82
|
}
|