@doccloak/core 0.9.0 → 0.11.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.
@@ -0,0 +1,92 @@
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
+ import type { DetectedEntity, DetectionProvider, ProgressCallback } from '../types.js';
19
+ import type { CoreEnv } from '../env.js';
20
+ import type { ModelVerification } from '../model-loader.js';
21
+ import { type RawSpan } from './gliner.js';
22
+ export declare const GLINER_BASE_MODEL_REVISION = "61726e0ad791dcab3e29339bbec3ad42ded65641";
23
+ /** SHA-256 of onnx/model_quint8.onnx (196,757,174 bytes) at GLINER_BASE_MODEL_REVISION. */
24
+ export declare const GLINER_BASE_MODEL_SHA256 = "0514c8fd86d0513ce5351a3267f132b57d5bcd8f99a90d43cde1228092881d19";
25
+ export declare const GLINER_BASE_MODEL_URL = "https://huggingface.co/knowledgator/gliner-pii-base-v1.0/resolve/61726e0ad791dcab3e29339bbec3ad42ded65641/onnx/model_quint8.onnx";
26
+ /** max_width from the repo's gliner_config.json: spans cover 1..12 words. */
27
+ export declare const GLINER_BASE_MAX_WIDTH = 12;
28
+ export interface SpanIndices {
29
+ /** Flattened (start, end) word pairs, shape [1, numSpans, 2]. */
30
+ spanIdx: BigInt64Array;
31
+ /** 1 for valid spans (end < textLength), 0 for padding, shape [1, numSpans]. */
32
+ spanMask: Uint8Array;
33
+ numSpans: number;
34
+ }
35
+ /**
36
+ * Enumerate all candidate word spans exactly like the python GLiNER
37
+ * SpanProcessor: start-major, width-minor - for every start word i, the
38
+ * pairs (i, i+0) .. (i, i+maxWidth-1). Spans that would run past the end
39
+ * of the text are masked out and their indices zeroed (mirroring
40
+ * span_idx * span_mask in the python collate).
41
+ */
42
+ export declare function buildSpanIndices(textLength: number, maxWidth?: number): SpanIndices;
43
+ /**
44
+ * Decode markerV0 span logits (shape [1, numWords, maxWidth, numClasses])
45
+ * into raw character spans: sigmoid per (start, width, class) cell,
46
+ * threshold, then map word indices to character offsets. Non-overlap
47
+ * selection (greedySelect) happens at the caller across chunks.
48
+ */
49
+ export declare function decodeSpanLogits(logits: Float32Array, dims: readonly number[], textLength: number, labels: string[], threshold: number, charStarts: number[], charEnds: number[], fullText: string): RawSpan[];
50
+ export declare class GlinerBaseProvider implements DetectionProvider {
51
+ private _name;
52
+ get name(): string;
53
+ private env;
54
+ private tokenizer;
55
+ private session;
56
+ private loading;
57
+ private loadError;
58
+ private loadWaiters;
59
+ private progressCallback;
60
+ private threshold;
61
+ private customLabels;
62
+ private verification;
63
+ constructor(env: CoreEnv);
64
+ /**
65
+ * SHA-256 verification result of the served model blob (null until the
66
+ * model has been verified against the pinned hash). Surfaced so the
67
+ * model-status UI can show the positive "Model verified" state.
68
+ */
69
+ getVerification(): ModelVerification | null;
70
+ isLoaded(): boolean;
71
+ isLoading(): boolean;
72
+ onProgress(callback: ProgressCallback): void;
73
+ setThreshold(value: number): void;
74
+ getThreshold(): number;
75
+ /** Get the combined list of all active labels (built-in + custom). */
76
+ private getActiveLabels;
77
+ /** Get user-defined custom labels. */
78
+ getCustomLabels(): string[];
79
+ /**
80
+ * Set custom labels and persist through the injected KV store. The
81
+ * in-memory list updates synchronously (before the first await), so
82
+ * fire-and-forget callers keep the previous behavior.
83
+ */
84
+ setCustomLabels(labels: string[]): Promise<void>;
85
+ /** Restore custom labels from the injected KV store on startup. */
86
+ restoreCustomLabels(): Promise<void>;
87
+ load(): Promise<void>;
88
+ release(): void;
89
+ detect(text: string, onProgress?: (progress: number) => void): Promise<DetectedEntity[]>;
90
+ private buildPromptTokens;
91
+ private inferChunk;
92
+ }
@@ -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 Edge provider.
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 = "9b7f39b0a2da971a5beea78d35f1539d4009c891";
13
- /** SHA-256 of onnx/model_quint8.onnx (45,820,894 bytes) at GLINER_MODEL_REVISION. */
14
- export declare const GLINER_MODEL_SHA256 = "988acb03456b26e2d9f2521016d820310c2ed64deb4a846297d3289f0c2eb7e4";
15
- export declare const GLINER_MODEL_URL = "https://huggingface.co/knowledgator/gliner-pii-edge-v1.0/resolve/9b7f39b0a2da971a5beea78d35f1539d4009c891/onnx/model_quint8.onnx";
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;
@@ -1,12 +1,15 @@
1
1
  /**
2
- * GLiNER PII Edge provider.
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 * as ort from 'onnxruntime-web';
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-edge-v1.0 as of its last
19
- // modification 2026-03-26; pinned and hashed 2026-08-10.
20
- export const GLINER_MODEL_REVISION = '9b7f39b0a2da971a5beea78d35f1539d4009c891';
21
- /** SHA-256 of onnx/model_quint8.onnx (45,820,894 bytes) at GLINER_MODEL_REVISION. */
22
- export const GLINER_MODEL_SHA256 = '988acb03456b26e2d9f2521016d820310c2ed64deb4a846297d3289f0c2eb7e4';
23
- export const GLINER_MODEL_URL = `https://huggingface.co/knowledgator/gliner-pii-edge-v1.0/resolve/${GLINER_MODEL_REVISION}/onnx/model_quint8.onnx`;
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
- /** Pre-pinning download URL; its cache entry is evicted best-effort on load. */
26
- const LEGACY_MODEL_URL = 'https://huggingface.co/knowledgator/gliner-pii-edge-v1.0/resolve/main/onnx/model_quint8.onnx';
27
- const DEFAULT_TOKENIZER_HF = 'knowledgator/gliner-pii-edge-v1.0';
28
- const DEFAULT_MODEL_NAME = 'GLiNER PII Edge';
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
- const DEFAULT_PII_LABELS = [
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
- function mapLabelToEntityType(label) {
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 entry from the pre-pinning resolve/main URL - drop it so
203
- // a 45 MB stale copy does not linger against the origin quota.
204
- void evictModelFromCache(loaderEnv, LEGACY_MODEL_URL);
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/dist/session.d.ts CHANGED
@@ -16,6 +16,11 @@ export declare class AnonymizationSession {
16
16
  private reverseMap;
17
17
  private entityTypeMap;
18
18
  private typeCounters;
19
+ /**
20
+ * Variant token -> its group's base token and suffix (T171), so renaming
21
+ * the base re-derives its variants and deserialized maps re-link.
22
+ */
23
+ private variantOf;
19
24
  private mode;
20
25
  /** Surrogate-mode salt (T043); constant for the session's lifetime. */
21
26
  private salt;
@@ -46,6 +51,27 @@ export declare class AnonymizationSession {
46
51
  */
47
52
  private findPersonVariant;
48
53
  anonymize(original: string, entityType: EntityType): string;
54
+ /**
55
+ * The group a mapped value belongs to (T171): its base token and the
56
+ * canonical original that token restores to.
57
+ */
58
+ private variantGroup;
59
+ /**
60
+ * Labeled-mode variant token (T171): the group's base token plus a
61
+ * positional suffix, computed against the group's canonical name when
62
+ * the variant is part of it, else against the value it matched. Distinct
63
+ * originals never share a token: a suffix already in use gets a counter
64
+ * ("Smith" -> [PERSON_2_LAST], "smith" -> [PERSON_2_LAST_2]).
65
+ */
66
+ private variantPlaceholder;
67
+ /**
68
+ * Surrogate-mode variant (T171): when the variant is a strict part of the
69
+ * group's canonical name and the surrogate has the same word count, take
70
+ * the surrogate words at the same positions with the variant's casing.
71
+ * Anything else (fuller forms, reshaped surrogates, a collision with an
72
+ * existing value) falls back to the group's surrogate as before.
73
+ */
74
+ private variantSurrogate;
49
75
  deanonymize(text: string): string;
50
76
  /**
51
77
  * Tolerant pass (T098) over text the exact pass already handled: scan
@@ -63,7 +89,16 @@ export declare class AnonymizationSession {
63
89
  anonymizeText(text: string, entities: DetectedEntity[]): string;
64
90
  getEntries(): ReplacementEntry[];
65
91
  getForward(original: string): string | undefined;
66
- renameLabel(original: string, newLabel: string): void;
92
+ /**
93
+ * Rename the label of a mapped value. Renaming a group's base token
94
+ * (T171) re-derives every variant token from the new label
95
+ * ([PERSON_1] -> [CLIENT] takes [PERSON_1_LAST] to [CLIENT_LAST]);
96
+ * renaming a variant token renames only that token and detaches it from
97
+ * the group, so a later base rename does not overwrite the user's choice.
98
+ * Returns every [oldLabel, newLabel] pair applied, so hosts can update
99
+ * already-rendered text without re-anonymizing.
100
+ */
101
+ renameLabel(original: string, newLabel: string): Array<[string, string]>;
67
102
  /**
68
103
  * Serialize the session map to JSON.
69
104
  *