@stabrise/scaledp 0.1.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 +661 -0
- package/README.md +218 -0
- package/dist/box-DAfzwfhA.d.ts +119 -0
- package/dist/config-g6IrKlDC.d.ts +80 -0
- package/dist/data-to-image-DoZ4jQ3R.js +54 -0
- package/dist/data-to-image-DoZ4jQ3R.js.map +1 -0
- package/dist/detect/index.d.ts +71 -0
- package/dist/detect/index.js +2 -0
- package/dist/detect-q8AI_Jdj.js +274 -0
- package/dist/detect-q8AI_Jdj.js.map +1 -0
- package/dist/detector-output-C0Qt-jEq.d.ts +13 -0
- package/dist/detector-output-lyF1Mqb8.js +13 -0
- package/dist/detector-output-lyF1Mqb8.js.map +1 -0
- package/dist/display/index.d.ts +66 -0
- package/dist/display/index.js +237 -0
- package/dist/display/index.js.map +1 -0
- package/dist/document-B8I61TiY.d.ts +16 -0
- package/dist/entity-CedtRhU1.d.ts +22 -0
- package/dist/entity-D6Hxaugj.js +13 -0
- package/dist/entity-D6Hxaugj.js.map +1 -0
- package/dist/image-CAH2rLv9.js +511 -0
- package/dist/image-CAH2rLv9.js.map +1 -0
- package/dist/image-Dc5TSg46.d.ts +18 -0
- package/dist/image-DoZDJkcR.js +37 -0
- package/dist/image-DoZDJkcR.js.map +1 -0
- package/dist/image-draw-boxes-De0QbFv9.js +285 -0
- package/dist/image-draw-boxes-De0QbFv9.js.map +1 -0
- package/dist/index.d.ts +269 -0
- package/dist/index.js +11 -0
- package/dist/model-cache-BEaqqRZ9.js +182 -0
- package/dist/model-cache-BEaqqRZ9.js.map +1 -0
- package/dist/model-cache-BhFYpfZz.d.ts +36 -0
- package/dist/ner/index.d.ts +293 -0
- package/dist/ner/index.js +2 -0
- package/dist/ner-SsZLZ6ed.js +1028 -0
- package/dist/ner-SsZLZ6ed.js.map +1 -0
- package/dist/ocr/index.d.ts +440 -0
- package/dist/ocr/index.js +3 -0
- package/dist/ocr-OHX2WM3e.js +1294 -0
- package/dist/ocr-OHX2WM3e.js.map +1 -0
- package/dist/ort-CXDoPrtw.js +73 -0
- package/dist/ort-CXDoPrtw.js.map +1 -0
- package/dist/params-DapwK9Ns.js +37 -0
- package/dist/params-DapwK9Ns.js.map +1 -0
- package/dist/pdf/index.d.ts +123 -0
- package/dist/pdf/index.js +2 -0
- package/dist/pdf-BQl0dneD.js +417 -0
- package/dist/pdf-BQl0dneD.js.map +1 -0
- package/dist/pipeline-DACqGkpN.js +240 -0
- package/dist/pipeline-DACqGkpN.js.map +1 -0
- package/dist/pipeline-DeLO-OCE.d.ts +139 -0
- package/dist/registry/index.d.ts +169 -0
- package/dist/registry/index.js +1061 -0
- package/dist/registry/index.js.map +1 -0
- package/dist/text-ahMLpxN9.js +109 -0
- package/dist/text-ahMLpxN9.js.map +1 -0
- package/dist/worker/index.d.ts +105 -0
- package/dist/worker/index.js +180 -0
- package/dist/worker/index.js.map +1 -0
- package/package.json +135 -0
|
@@ -0,0 +1,1028 @@
|
|
|
1
|
+
import { h as getConfig, i as Stage, l as NerError } from "./pipeline-DACqGkpN.js";
|
|
2
|
+
import { n as ensureModelFiles } from "./model-cache-BEaqqRZ9.js";
|
|
3
|
+
import { i as resolveParams, t as BASE_STAGE_DEFAULTS } from "./params-DapwK9Ns.js";
|
|
4
|
+
import { t as createNerOutput } from "./entity-D6Hxaugj.js";
|
|
5
|
+
import { t as createSession } from "./ort-CXDoPrtw.js";
|
|
6
|
+
//#region src/ner/chunking.ts
|
|
7
|
+
/** Python's `split_text` default, and what the cloud /ner/text endpoint uses. */
|
|
8
|
+
const DEFAULT_CHUNK_LENGTH = 500;
|
|
9
|
+
/** 500 - 480 leaves a 20-character overlap so entities on a seam survive. */
|
|
10
|
+
const DEFAULT_CHUNK_STRIDE = 480;
|
|
11
|
+
function chunkText(text, maxLength = 500, stride = 480) {
|
|
12
|
+
if (stride <= 0) throw new RangeError(`stride must be positive, received ${stride}`);
|
|
13
|
+
if (maxLength <= 0) throw new RangeError(`maxLength must be positive, received ${maxLength}`);
|
|
14
|
+
if (text.length === 0) return [];
|
|
15
|
+
if (text.length <= maxLength) return [{
|
|
16
|
+
text,
|
|
17
|
+
offset: 0
|
|
18
|
+
}];
|
|
19
|
+
const chunks = [];
|
|
20
|
+
for (let offset = 0; offset < text.length; offset += stride) {
|
|
21
|
+
const slice = text.slice(offset, offset + maxLength);
|
|
22
|
+
chunks.push({
|
|
23
|
+
text: slice,
|
|
24
|
+
offset
|
|
25
|
+
});
|
|
26
|
+
if (slice.length < maxLength) break;
|
|
27
|
+
}
|
|
28
|
+
return chunks;
|
|
29
|
+
}
|
|
30
|
+
/** Shift a chunk-local span onto the original text's coordinates. */
|
|
31
|
+
function rebaseSpan(span, offset) {
|
|
32
|
+
return {
|
|
33
|
+
...span,
|
|
34
|
+
start: span.start + offset,
|
|
35
|
+
end: span.end + offset
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Drop duplicates produced by the chunk overlap, keeping the highest score for
|
|
40
|
+
* each distinct (start, end, label).
|
|
41
|
+
*/
|
|
42
|
+
function dedupeSpans(spans) {
|
|
43
|
+
const best = /* @__PURE__ */ new Map();
|
|
44
|
+
for (const span of spans) {
|
|
45
|
+
const key = `${span.start}:${span.end}:${span.label}`;
|
|
46
|
+
const existing = best.get(key);
|
|
47
|
+
if (!existing || span.score > existing.score) best.set(key, span);
|
|
48
|
+
}
|
|
49
|
+
return [...best.values()].sort((a, b) => a.start - b.start);
|
|
50
|
+
}
|
|
51
|
+
/** Ratio of uppercase to cased letters. */
|
|
52
|
+
function uppercaseRatio(text) {
|
|
53
|
+
let upper = 0;
|
|
54
|
+
let lower = 0;
|
|
55
|
+
for (const char of text) if (char >= "A" && char <= "Z") upper++;
|
|
56
|
+
else if (char >= "a" && char <= "z") lower++;
|
|
57
|
+
else if (char !== char.toLowerCase()) upper++;
|
|
58
|
+
else if (char !== char.toUpperCase()) lower++;
|
|
59
|
+
return upper + lower === 0 ? 0 : upper / (upper + lower);
|
|
60
|
+
}
|
|
61
|
+
const ALL_CAPS_RATIO = .6;
|
|
62
|
+
function isMostlyUppercase(text) {
|
|
63
|
+
return uppercaseRatio(text) > ALL_CAPS_RATIO;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Title-case runs of capitals, preserving length.
|
|
67
|
+
*
|
|
68
|
+
* GLiNER1 models are cased and scanned documents are frequently set in all
|
|
69
|
+
* caps, which reads to the model as unlike anything in training. Length
|
|
70
|
+
* preservation is essential: every character offset the decoder returns is used
|
|
71
|
+
* to index the original text.
|
|
72
|
+
*/
|
|
73
|
+
function titleCaseAllCapsWords(text) {
|
|
74
|
+
return text.replace(/\p{Lu}[\p{Lu}\p{N}'’-]*\p{Lu}/gu, (word) => {
|
|
75
|
+
const titled = word[0] + word.slice(1).toLowerCase();
|
|
76
|
+
return titled.length === word.length ? titled : word;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** Apply the casing fix only when the text is predominantly uppercase. */
|
|
80
|
+
function normaliseCasing(text) {
|
|
81
|
+
return isMostlyUppercase(text) ? titleCaseAllCapsWords(text) : text;
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/ner/vendor/math.ts
|
|
85
|
+
/** Numeric helpers for the GLiNER runtimes. Adapted from @lmoe/gliner-onnx (MIT). */
|
|
86
|
+
/** Numerically stable sigmoid: exp(-x) overflows for large negative x. */
|
|
87
|
+
function sigmoid(x) {
|
|
88
|
+
if (x >= 0) return 1 / (1 + Math.exp(-x));
|
|
89
|
+
const expX = Math.exp(x);
|
|
90
|
+
return expX / (1 + expX);
|
|
91
|
+
}
|
|
92
|
+
function softmax(values) {
|
|
93
|
+
let max = Number.NEGATIVE_INFINITY;
|
|
94
|
+
for (let i = 0; i < values.length; i++) {
|
|
95
|
+
const v = values[i];
|
|
96
|
+
if (v > max) max = v;
|
|
97
|
+
}
|
|
98
|
+
const out = new Float32Array(values.length);
|
|
99
|
+
let sum = 0;
|
|
100
|
+
for (let i = 0; i < values.length; i++) {
|
|
101
|
+
const e = Math.exp(values[i] - max);
|
|
102
|
+
out[i] = e;
|
|
103
|
+
sum += e;
|
|
104
|
+
}
|
|
105
|
+
for (let i = 0; i < out.length; i++) out[i] = out[i] / sum;
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
/** Gather rows out of a flat [n, hiddenSize] matrix. */
|
|
109
|
+
function gatherRows(source, positions, hiddenSize) {
|
|
110
|
+
const out = new Float32Array(positions.length * hiddenSize);
|
|
111
|
+
for (let i = 0; i < positions.length; i++) {
|
|
112
|
+
const from = positions[i] * hiddenSize;
|
|
113
|
+
out.set(source.subarray(from, from + hiddenSize), i * hiddenSize);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
/** Contiguous row slice of a flat [n, hiddenSize] matrix. */
|
|
118
|
+
function sliceRows(source, startRow, rowCount, hiddenSize) {
|
|
119
|
+
const from = startRow * hiddenSize;
|
|
120
|
+
return source.slice(from, from + rowCount * hiddenSize);
|
|
121
|
+
}
|
|
122
|
+
/** Token ids from a transformers.js `tolist()` result, flattened and de-bigint'd. */
|
|
123
|
+
function extractTokenIds(tolistResult) {
|
|
124
|
+
return (Array.isArray(tolistResult[0]) ? tolistResult.flat() : tolistResult).map((v) => typeof v === "bigint" ? Number(v) : v);
|
|
125
|
+
}
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/ner/tokenizer-types.ts
|
|
128
|
+
/** Shared tokenizer adaptation, kept separate so backends need not import the loader. */
|
|
129
|
+
/**
|
|
130
|
+
* Adapt a transformers.js tokenizer to the span processor's interface.
|
|
131
|
+
*
|
|
132
|
+
* CLS/SEP ids are derived empirically -- encode a throwaway token, read the
|
|
133
|
+
* first and last id -- rather than read from `cls_token_id`. Not every GLiNER
|
|
134
|
+
* repo populates those fields, and a wrong id corrupts every sequence silently
|
|
135
|
+
* instead of failing loudly.
|
|
136
|
+
*/
|
|
137
|
+
function toSpanTokenizer(tokenizer) {
|
|
138
|
+
const encode = (text) => extractTokenIds(tokenizer(text, { add_special_tokens: true }).input_ids.tolist());
|
|
139
|
+
const probe = encode("x");
|
|
140
|
+
if (probe.length < 2) throw new NerError("Tokenizer produced no special tokens; cannot derive CLS/SEP ids", "toSpanTokenizer");
|
|
141
|
+
return {
|
|
142
|
+
encode,
|
|
143
|
+
clsTokenId: probe[0],
|
|
144
|
+
sepTokenId: probe[probe.length - 1]
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region src/ner/vendor/span-decoder.ts
|
|
149
|
+
/**
|
|
150
|
+
* GLiNER1 span decoder. Adapted from @lmoe/gliner-onnx (MIT).
|
|
151
|
+
*
|
|
152
|
+
* The model emits logits shaped [batch, seqLen, maxWidth, entityCount]. Every
|
|
153
|
+
* (start word, width, label) triple above threshold becomes a candidate span;
|
|
154
|
+
* greedy non-maximum suppression then keeps the highest-scoring
|
|
155
|
+
* non-overlapping set.
|
|
156
|
+
*/
|
|
157
|
+
/** Model class ids are 1-based; index 0 is reserved. */
|
|
158
|
+
const ENTITY_ID_OFFSET = 1;
|
|
159
|
+
function spansOverlap(aStart, aEnd, bStart, bEnd, allowNested, allowMultiLabel) {
|
|
160
|
+
if (aStart === bStart && aEnd === bEnd) return !allowMultiLabel;
|
|
161
|
+
if (aStart > bEnd || bStart > aEnd) return false;
|
|
162
|
+
if (allowNested) {
|
|
163
|
+
if (aStart <= bStart && aEnd >= bEnd || bStart <= aStart && bEnd >= aEnd) return false;
|
|
164
|
+
}
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
/** Keep the highest-scoring spans that do not collide. */
|
|
168
|
+
function greedySearch(spans, flatNer, multiLabel) {
|
|
169
|
+
const byScore = [...spans].sort((a, b) => b.score - a.score);
|
|
170
|
+
const kept = [];
|
|
171
|
+
for (const span of byScore) if (!kept.some((other) => spansOverlap(span.start, span.end, other.start, other.end, !flatNer, multiLabel))) kept.push(span);
|
|
172
|
+
return kept.sort((a, b) => a.start - b.start);
|
|
173
|
+
}
|
|
174
|
+
function decodeSpans(logits, params, options = {}) {
|
|
175
|
+
const threshold = options.threshold ?? .5;
|
|
176
|
+
const flatNer = options.flatNer ?? true;
|
|
177
|
+
const multiLabel = options.multiLabel ?? false;
|
|
178
|
+
const { batchSize, inputLength, maxWidth, entityCount } = params;
|
|
179
|
+
const batchStride = inputLength * maxWidth * entityCount;
|
|
180
|
+
const tokenStride = maxWidth * entityCount;
|
|
181
|
+
const spans = Array.from({ length: batchSize }, () => []);
|
|
182
|
+
for (let index = 0; index < logits.length; index++) {
|
|
183
|
+
const score = sigmoid(logits[index]);
|
|
184
|
+
if (score < threshold) continue;
|
|
185
|
+
const batchIdx = Math.floor(index / batchStride);
|
|
186
|
+
const startWord = Math.floor(index / tokenStride) % inputLength;
|
|
187
|
+
const endWord = startWord + Math.floor(index / entityCount) % maxWidth;
|
|
188
|
+
const entityIdx = index % entityCount;
|
|
189
|
+
const words = params.batchWords[batchIdx];
|
|
190
|
+
if (!words || startWord >= words.length || endWord >= words.length) continue;
|
|
191
|
+
const start = words[startWord][1];
|
|
192
|
+
const end = words[endWord][2];
|
|
193
|
+
const text = params.texts[batchIdx] ?? "";
|
|
194
|
+
spans[batchIdx].push({
|
|
195
|
+
text: text.slice(start, end),
|
|
196
|
+
label: params.idToClass[entityIdx + ENTITY_ID_OFFSET] ?? "",
|
|
197
|
+
start,
|
|
198
|
+
end,
|
|
199
|
+
score
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return spans.map((batch) => greedySearch(batch, flatNer, multiLabel));
|
|
203
|
+
}
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/ner/vendor/splitter.ts
|
|
206
|
+
/** Word splitting for GLiNER. Adapted from @lmoe/gliner-onnx (MIT). */
|
|
207
|
+
/**
|
|
208
|
+
* Unicode word classes (`\p{L}\p{N}` with the `u` flag), deliberately not `\w`.
|
|
209
|
+
*
|
|
210
|
+
* JavaScript's `\w` is ASCII-only even under `/u`, so it would shatter accented
|
|
211
|
+
* names -- "Müller", "García" -- into single-character tokens and wreck
|
|
212
|
+
* multi-word span detection in German, Polish and Spanish text. Python's `\w`
|
|
213
|
+
* is Unicode-aware, so this restores parity with the reference tokenizer.
|
|
214
|
+
*/
|
|
215
|
+
const WORD_PATTERN = /[\p{L}\p{N}_]+(?:[-_][\p{L}\p{N}_]+)*|\S/gu;
|
|
216
|
+
/**
|
|
217
|
+
* As above, plus leading branches for URLs, emails and @mentions so they stay
|
|
218
|
+
* whole. Those branches are ASCII on purpose, mirroring the Python pattern.
|
|
219
|
+
*/
|
|
220
|
+
const RICH_WORD_PATTERN = /(?:https?:\/\/[^\s]+|www\.[^\s]+)|[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}|@[a-z0-9_]+|[\p{L}\p{N}_]+(?:[-_][\p{L}\p{N}_]+)*|\S/giu;
|
|
221
|
+
/**
|
|
222
|
+
* Split text into words with their character offsets.
|
|
223
|
+
*
|
|
224
|
+
* A fresh RegExp per call: the `g` flag makes `lastIndex` stateful, so sharing
|
|
225
|
+
* one instance across calls silently skips matches.
|
|
226
|
+
*/
|
|
227
|
+
function splitWords(text, pattern = WORD_PATTERN) {
|
|
228
|
+
const regex = new RegExp(pattern.source, pattern.flags);
|
|
229
|
+
const out = [];
|
|
230
|
+
for (;;) {
|
|
231
|
+
const match = regex.exec(text);
|
|
232
|
+
if (match === null) break;
|
|
233
|
+
out.push([
|
|
234
|
+
match[0],
|
|
235
|
+
match.index,
|
|
236
|
+
regex.lastIndex
|
|
237
|
+
]);
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
//#endregion
|
|
242
|
+
//#region src/ner/vendor/span-processor.ts
|
|
243
|
+
/**
|
|
244
|
+
* GLiNER1 span-enumeration input builder. Adapted from @lmoe/gliner-onnx (MIT).
|
|
245
|
+
*
|
|
246
|
+
* Builds the prompt `<<ENT>> label1 <<ENT>> label2 <<SEP>> text` and enumerates
|
|
247
|
+
* every candidate span up to `maxWidth` words, which is what the model scores.
|
|
248
|
+
*/
|
|
249
|
+
const PAD = 0;
|
|
250
|
+
function pad(arrays, value) {
|
|
251
|
+
const max = Math.max(...arrays.map((a) => a.length));
|
|
252
|
+
return arrays.map((a) => [...a, ...new Array(max - a.length).fill(value)]);
|
|
253
|
+
}
|
|
254
|
+
var SpanProcessor = class {
|
|
255
|
+
config;
|
|
256
|
+
tokenizer;
|
|
257
|
+
constructor(config, tokenizer) {
|
|
258
|
+
this.config = config;
|
|
259
|
+
this.tokenizer = tokenizer;
|
|
260
|
+
}
|
|
261
|
+
prepare(texts, labels) {
|
|
262
|
+
const batchWords = texts.map((text) => splitWords(text));
|
|
263
|
+
const idToClass = {};
|
|
264
|
+
for (const [i, label] of labels.entries()) idToClass[i + 1] = label;
|
|
265
|
+
const inputIds = [];
|
|
266
|
+
const attentionMasks = [];
|
|
267
|
+
const wordsMasks = [];
|
|
268
|
+
const textLengths = [];
|
|
269
|
+
const spanIdxs = [];
|
|
270
|
+
const spanMasks = [];
|
|
271
|
+
for (const words of batchWords) {
|
|
272
|
+
textLengths.push(words.length);
|
|
273
|
+
const prompt = [];
|
|
274
|
+
for (const label of labels) prompt.push(this.config.entToken, label);
|
|
275
|
+
prompt.push(this.config.sepToken);
|
|
276
|
+
const sequence = [...prompt, ...words.map(([w]) => w)];
|
|
277
|
+
const ids = [this.tokenizer.clsTokenId];
|
|
278
|
+
const attention = [1];
|
|
279
|
+
const wordsMask = [PAD];
|
|
280
|
+
let wordCounter = 1;
|
|
281
|
+
for (const [wordIdx, word] of sequence.entries()) {
|
|
282
|
+
const subTokens = this.tokenizer.encode(word).slice(1, -1);
|
|
283
|
+
for (const [tokenIdx, id] of subTokens.entries()) {
|
|
284
|
+
ids.push(id);
|
|
285
|
+
attention.push(1);
|
|
286
|
+
if (wordIdx < prompt.length) wordsMask.push(PAD);
|
|
287
|
+
else if (tokenIdx === 0) wordsMask.push(wordCounter++);
|
|
288
|
+
else wordsMask.push(PAD);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
ids.push(this.tokenizer.sepTokenId);
|
|
292
|
+
attention.push(1);
|
|
293
|
+
wordsMask.push(PAD);
|
|
294
|
+
inputIds.push(ids);
|
|
295
|
+
attentionMasks.push(attention);
|
|
296
|
+
wordsMasks.push(wordsMask);
|
|
297
|
+
const spanIdx = [];
|
|
298
|
+
const spanMask = [];
|
|
299
|
+
for (let start = 0; start < words.length; start++) for (let width = 0; width < this.config.maxWidth; width++) {
|
|
300
|
+
const end = Math.min(start + width, words.length - 1);
|
|
301
|
+
spanIdx.push([start, end]);
|
|
302
|
+
spanMask.push(end < words.length);
|
|
303
|
+
}
|
|
304
|
+
spanIdxs.push(spanIdx);
|
|
305
|
+
spanMasks.push(spanMask);
|
|
306
|
+
}
|
|
307
|
+
const maxSpans = Math.max(...spanIdxs.map((s) => s.length));
|
|
308
|
+
return {
|
|
309
|
+
inputIds: pad(inputIds, PAD),
|
|
310
|
+
attentionMasks: pad(attentionMasks, PAD),
|
|
311
|
+
wordsMasks: pad(wordsMasks, PAD),
|
|
312
|
+
textLengths,
|
|
313
|
+
spanIdxs: spanIdxs.map((s) => [...s, ...Array.from({ length: maxSpans - s.length }, () => [PAD, PAD])]),
|
|
314
|
+
spanMasks: pad(spanMasks, false),
|
|
315
|
+
idToClass,
|
|
316
|
+
batchWords
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
//#endregion
|
|
321
|
+
//#region src/ner/gliner1-backend.ts
|
|
322
|
+
/**
|
|
323
|
+
* GLiNER1 span-enumeration runtime on onnxruntime-web.
|
|
324
|
+
*
|
|
325
|
+
* One ONNX graph. Inputs:
|
|
326
|
+
* input_ids int64 [batch, tokens]
|
|
327
|
+
* attention_mask int64 [batch, tokens]
|
|
328
|
+
* words_mask int64 [batch, tokens] 1-based word index on first sub-token
|
|
329
|
+
* text_lengths int64 [batch, 1]
|
|
330
|
+
* span_idx int64 [batch, spans, 2]
|
|
331
|
+
* span_mask bool [batch, spans]
|
|
332
|
+
* Output:
|
|
333
|
+
* logits float32 [batch, words, maxWidth, entityCount]
|
|
334
|
+
*/
|
|
335
|
+
/** Fallback when a repo's config omits max_width. */
|
|
336
|
+
const DEFAULT_MAX_WIDTH = 12;
|
|
337
|
+
const DEFAULT_ENT_TOKEN = "<<ENT>>";
|
|
338
|
+
const DEFAULT_SEP_TOKEN = "<<SEP>>";
|
|
339
|
+
const CONFIG_CANDIDATES = ["gliner_config.json", "config.json"];
|
|
340
|
+
/** Some repos put the graph at the root rather than under onnx/. */
|
|
341
|
+
const MODEL_EXTENSIONS = [".onnx", ".model"];
|
|
342
|
+
const OUTPUT_LOGITS = "logits";
|
|
343
|
+
function readConfig(files) {
|
|
344
|
+
for (const name of CONFIG_CANDIDATES) {
|
|
345
|
+
const raw = files[name];
|
|
346
|
+
if (!raw) continue;
|
|
347
|
+
try {
|
|
348
|
+
const parsed = JSON.parse(new TextDecoder().decode(raw));
|
|
349
|
+
return {
|
|
350
|
+
maxWidth: Number(parsed.max_width) || DEFAULT_MAX_WIDTH,
|
|
351
|
+
entToken: String(parsed.ent_token ?? DEFAULT_ENT_TOKEN),
|
|
352
|
+
sepToken: String(parsed.sep_token ?? DEFAULT_SEP_TOKEN)
|
|
353
|
+
};
|
|
354
|
+
} catch {}
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
maxWidth: DEFAULT_MAX_WIDTH,
|
|
358
|
+
entToken: DEFAULT_ENT_TOKEN,
|
|
359
|
+
sepToken: DEFAULT_SEP_TOKEN
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function findModelBytes(files) {
|
|
363
|
+
for (const [path, bytes] of Object.entries(files)) if (MODEL_EXTENSIONS.some((ext) => path.endsWith(ext))) return bytes;
|
|
364
|
+
throw new NerError("No .onnx or .model file in the downloaded model", "Gliner1Backend");
|
|
365
|
+
}
|
|
366
|
+
var Gliner1Backend = class {
|
|
367
|
+
arch = "gliner1";
|
|
368
|
+
session = null;
|
|
369
|
+
processor = null;
|
|
370
|
+
config = null;
|
|
371
|
+
async load(files, options) {
|
|
372
|
+
this.config = readConfig(files);
|
|
373
|
+
this.processor = new SpanProcessor(this.config, toSpanTokenizer(options.tokenizer));
|
|
374
|
+
this.session = await createSession(findModelBytes(files), { executionProviders: options.executionProviders });
|
|
375
|
+
}
|
|
376
|
+
async extract(text, labels, threshold) {
|
|
377
|
+
const session = this.session;
|
|
378
|
+
const processor = this.processor;
|
|
379
|
+
const config = this.config;
|
|
380
|
+
if (!session || !processor || !config) throw new NerError("Backend used before load()", "Gliner1Backend");
|
|
381
|
+
if (labels.length === 0) return [];
|
|
382
|
+
const batch = processor.prepare([text], labels);
|
|
383
|
+
const words = batch.batchWords[0] ?? [];
|
|
384
|
+
if (words.length === 0) return [];
|
|
385
|
+
const { Tensor } = await import("onnxruntime-web");
|
|
386
|
+
const tokenCount = batch.inputIds[0].length;
|
|
387
|
+
const spanCount = batch.spanIdxs[0].length;
|
|
388
|
+
const big = (values) => BigInt64Array.from(values, BigInt);
|
|
389
|
+
const feeds = {
|
|
390
|
+
input_ids: new Tensor("int64", big(batch.inputIds.flat()), [1, tokenCount]),
|
|
391
|
+
attention_mask: new Tensor("int64", big(batch.attentionMasks.flat()), [1, tokenCount]),
|
|
392
|
+
words_mask: new Tensor("int64", big(batch.wordsMasks.flat()), [1, tokenCount]),
|
|
393
|
+
text_lengths: new Tensor("int64", big(batch.textLengths), [1, 1]),
|
|
394
|
+
span_idx: new Tensor("int64", big(batch.spanIdxs.flat(2)), [
|
|
395
|
+
1,
|
|
396
|
+
spanCount,
|
|
397
|
+
2
|
|
398
|
+
]),
|
|
399
|
+
span_mask: new Tensor("bool", Uint8Array.from(batch.spanMasks.flat(), (v) => v ? 1 : 0), [1, spanCount])
|
|
400
|
+
};
|
|
401
|
+
const inputs = {};
|
|
402
|
+
for (const name of session.inputNames) {
|
|
403
|
+
const tensor = feeds[name];
|
|
404
|
+
if (!tensor) throw new NerError(`Model expects unknown input "${name}"`, "Gliner1Backend");
|
|
405
|
+
inputs[name] = tensor;
|
|
406
|
+
}
|
|
407
|
+
const outputs = await session.run(inputs);
|
|
408
|
+
const logits = (outputs[OUTPUT_LOGITS] ?? outputs[session.outputNames[0] ?? ""])?.data;
|
|
409
|
+
if (!logits) throw new NerError("Model produced no logits", "Gliner1Backend");
|
|
410
|
+
const [spans = []] = decodeSpans(logits, {
|
|
411
|
+
batchSize: 1,
|
|
412
|
+
inputLength: words.length,
|
|
413
|
+
maxWidth: config.maxWidth,
|
|
414
|
+
entityCount: labels.length,
|
|
415
|
+
texts: [text],
|
|
416
|
+
batchWords: batch.batchWords,
|
|
417
|
+
idToClass: batch.idToClass
|
|
418
|
+
}, {
|
|
419
|
+
threshold,
|
|
420
|
+
flatNer: true,
|
|
421
|
+
multiLabel: false
|
|
422
|
+
});
|
|
423
|
+
return spans;
|
|
424
|
+
}
|
|
425
|
+
async dispose() {
|
|
426
|
+
await this.session?.release();
|
|
427
|
+
this.session = null;
|
|
428
|
+
this.processor = null;
|
|
429
|
+
this.config = null;
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
//#endregion
|
|
433
|
+
//#region src/ner/vendor/gliner2-decoder.ts
|
|
434
|
+
/** GLiNER2 span generation and decoding. Adapted from @lmoe/gliner-onnx (MIT). */
|
|
435
|
+
/**
|
|
436
|
+
* Every span up to `maxWidth` words.
|
|
437
|
+
*
|
|
438
|
+
* Out-of-range slots are filled with (0, 0) rather than dropped, because the
|
|
439
|
+
* span axis must stay a fixed `seqLen * maxWidth` for the ONNX graph. The
|
|
440
|
+
* decoder skips them by re-checking the word bounds.
|
|
441
|
+
*/
|
|
442
|
+
function generateSpans(seqLen, maxWidth) {
|
|
443
|
+
const spanStart = [];
|
|
444
|
+
const spanEnd = [];
|
|
445
|
+
for (let i = 0; i < seqLen; i++) for (let j = 0; j < maxWidth; j++) {
|
|
446
|
+
const inRange = i + j < seqLen;
|
|
447
|
+
spanStart.push(inRange ? i : 0);
|
|
448
|
+
spanEnd.push(inRange ? i + j : 0);
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
spanStart,
|
|
452
|
+
spanEnd,
|
|
453
|
+
spanCount: spanStart.length
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
/** Sigmoid of the dot product between each span and each label embedding. */
|
|
457
|
+
function computeDotProductScores(spanRep, labelRep, spanCount, labelCount, hiddenSize) {
|
|
458
|
+
const scores = new Float32Array(spanCount * labelCount);
|
|
459
|
+
for (let s = 0; s < spanCount; s++) {
|
|
460
|
+
const spanOffset = s * hiddenSize;
|
|
461
|
+
for (let l = 0; l < labelCount; l++) {
|
|
462
|
+
const labelOffset = l * hiddenSize;
|
|
463
|
+
let dot = 0;
|
|
464
|
+
for (let h = 0; h < hiddenSize; h++) dot += spanRep[spanOffset + h] * labelRep[labelOffset + h];
|
|
465
|
+
scores[s * labelCount + l] = sigmoid(dot);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return scores;
|
|
469
|
+
}
|
|
470
|
+
/** Score matrix -> entities, then drop same-label overlaps keeping the best. */
|
|
471
|
+
function decodeEntities(scoreData, wordCount, labels, wordOffsets, text, threshold) {
|
|
472
|
+
const { scores, wordSpanStart, wordSpanEnd, spanCount } = scoreData;
|
|
473
|
+
const labelCount = labels.length;
|
|
474
|
+
const entities = [];
|
|
475
|
+
for (let s = 0; s < spanCount; s++) {
|
|
476
|
+
const startWord = wordSpanStart[s];
|
|
477
|
+
const endWord = wordSpanEnd[s];
|
|
478
|
+
if (startWord >= wordCount || endWord >= wordCount) continue;
|
|
479
|
+
for (let l = 0; l < labelCount; l++) {
|
|
480
|
+
const score = scores[s * labelCount + l];
|
|
481
|
+
if (score < threshold) continue;
|
|
482
|
+
const start = wordOffsets[startWord][0];
|
|
483
|
+
const end = wordOffsets[endWord][1];
|
|
484
|
+
entities.push({
|
|
485
|
+
text: text.slice(start, end),
|
|
486
|
+
label: labels[l],
|
|
487
|
+
start,
|
|
488
|
+
end,
|
|
489
|
+
score
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return deduplicateEntities(entities);
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Keep the highest-scoring entity among overlapping ones *of the same label*.
|
|
497
|
+
* Different labels may overlap: a person name inside an organization is a
|
|
498
|
+
* legitimate reading, not a conflict.
|
|
499
|
+
*/
|
|
500
|
+
function deduplicateEntities(entities) {
|
|
501
|
+
const byScore = [...entities].sort((a, b) => b.score - a.score);
|
|
502
|
+
const kept = [];
|
|
503
|
+
for (const entity of byScore) if (!kept.some((other) => entity.label === other.label && entity.start < other.end && entity.end > other.start)) kept.push(entity);
|
|
504
|
+
return kept.sort((a, b) => a.start - b.start);
|
|
505
|
+
}
|
|
506
|
+
//#endregion
|
|
507
|
+
//#region src/ner/gliner2-backend.ts
|
|
508
|
+
/**
|
|
509
|
+
* GLiNER2 runtime on onnxruntime-web.
|
|
510
|
+
*
|
|
511
|
+
* Three graphs, orchestrated in JS:
|
|
512
|
+
* encoder input_ids, attention_mask [1, seq] int64 -> hidden states
|
|
513
|
+
* span_rep hidden_states [1, seq, hidden] + span_start_idx/span_end_idx
|
|
514
|
+
* [1, spans] int64 -> span representations
|
|
515
|
+
* count_embed label_embeddings [labels, hidden] -> transformed embeddings
|
|
516
|
+
*
|
|
517
|
+
* The `classifier` graph is for text classification only and is not downloaded.
|
|
518
|
+
* Scoring (dot product + sigmoid) happens in JS between span_rep and
|
|
519
|
+
* count_embed.
|
|
520
|
+
*
|
|
521
|
+
* Unlike GLiNER1's single prompt string, GLiNER2 builds a *schema*:
|
|
522
|
+
* ( [P] entities ( [E] label1 [E] label2 ) ) [SEP_TEXT] <text>
|
|
523
|
+
* with each label's position recorded so its embedding can be gathered from
|
|
524
|
+
* the encoder output.
|
|
525
|
+
*/
|
|
526
|
+
const CONFIG_FILE = "config.json";
|
|
527
|
+
const GLINER2_CONFIG_FILE = "gliner2_config.json";
|
|
528
|
+
const TOKEN_P = "[P]";
|
|
529
|
+
const TOKEN_E = "[E]";
|
|
530
|
+
const TOKEN_SEP_TEXT = "[SEP_TEXT]";
|
|
531
|
+
const SCHEMA_OPEN = "(";
|
|
532
|
+
const SCHEMA_CLOSE = ")";
|
|
533
|
+
const NER_TASK_NAME = "entities";
|
|
534
|
+
function decodeJson(buffer, name) {
|
|
535
|
+
if (!buffer) throw new NerError(`Model is missing ${name}`, "Gliner2Backend");
|
|
536
|
+
return JSON.parse(new TextDecoder().decode(buffer));
|
|
537
|
+
}
|
|
538
|
+
var Gliner2Backend = class {
|
|
539
|
+
arch = "gliner2";
|
|
540
|
+
config = null;
|
|
541
|
+
encoder = null;
|
|
542
|
+
spanRep = null;
|
|
543
|
+
countEmbed = null;
|
|
544
|
+
tokenize = null;
|
|
545
|
+
async load(files, options) {
|
|
546
|
+
const transformer = decodeJson(files[CONFIG_FILE], CONFIG_FILE);
|
|
547
|
+
const gliner2 = decodeJson(files[GLINER2_CONFIG_FILE], GLINER2_CONFIG_FILE);
|
|
548
|
+
if (typeof transformer.hidden_size !== "number") throw new NerError(`${CONFIG_FILE} is missing hidden_size`, "Gliner2Backend");
|
|
549
|
+
if (typeof gliner2.max_width !== "number") throw new NerError(`${GLINER2_CONFIG_FILE} is missing max_width`, "Gliner2Backend");
|
|
550
|
+
this.config = {
|
|
551
|
+
hiddenSize: transformer.hidden_size,
|
|
552
|
+
maxWidth: gliner2.max_width,
|
|
553
|
+
specialTokens: gliner2.special_tokens
|
|
554
|
+
};
|
|
555
|
+
const tokenizer = options.tokenizer;
|
|
556
|
+
this.tokenize = (text) => extractTokenIds(tokenizer(text, { add_special_tokens: false }).input_ids.tolist());
|
|
557
|
+
const graph = (path) => {
|
|
558
|
+
const bytes = files[path];
|
|
559
|
+
if (!bytes) throw new NerError(`Model is missing ${path}`, "Gliner2Backend");
|
|
560
|
+
return bytes;
|
|
561
|
+
};
|
|
562
|
+
const providers = options.executionProviders ?? ["wasm"];
|
|
563
|
+
const [encoder, spanRep, countEmbed] = await Promise.all([
|
|
564
|
+
createSession(graph("onnx/encoder.onnx"), { executionProviders: providers }),
|
|
565
|
+
createSession(graph("onnx/span_rep.onnx"), { executionProviders: providers }),
|
|
566
|
+
createSession(graph("onnx/count_embed.onnx"), { executionProviders: providers })
|
|
567
|
+
]);
|
|
568
|
+
this.encoder = encoder;
|
|
569
|
+
this.spanRep = spanRep;
|
|
570
|
+
this.countEmbed = countEmbed;
|
|
571
|
+
}
|
|
572
|
+
async extract(text, labels, threshold) {
|
|
573
|
+
const { config, tokenize } = this;
|
|
574
|
+
if (!config || !tokenize || !this.encoder || !this.spanRep || !this.countEmbed) throw new NerError("Backend used before load()", "Gliner2Backend");
|
|
575
|
+
if (text.trim().length === 0 || labels.length === 0) return [];
|
|
576
|
+
const schema = this.buildSchema(labels);
|
|
577
|
+
const words = this.tokenizeWords(text);
|
|
578
|
+
if (words.wordOffsets.length === 0) return [];
|
|
579
|
+
const allTokens = [...schema.tokens, ...words.tokens];
|
|
580
|
+
const hidden = await this.encode(allTokens);
|
|
581
|
+
const labelEmbeddings = gatherRows(hidden, schema.labelPositions, config.hiddenSize);
|
|
582
|
+
const textTokenCount = allTokens.length - schema.tokens.length;
|
|
583
|
+
if (textTokenCount === 0) return [];
|
|
584
|
+
const textHidden = sliceRows(hidden, schema.tokens.length, textTokenCount, config.hiddenSize);
|
|
585
|
+
const { spanStart, spanEnd, spanCount } = generateSpans(words.wordOffsets.length, config.maxWidth);
|
|
586
|
+
const toToken = (wordIdx) => words.firstTokenPositions[wordIdx] ?? 0;
|
|
587
|
+
return decodeEntities({
|
|
588
|
+
scores: computeDotProductScores(await this.runSpanRep(textHidden, textTokenCount, spanStart.map(toToken), spanEnd.map(toToken), spanCount), await this.runCountEmbed(labelEmbeddings, labels.length), spanCount, labels.length, config.hiddenSize),
|
|
589
|
+
wordSpanStart: spanStart,
|
|
590
|
+
wordSpanEnd: spanEnd,
|
|
591
|
+
spanCount
|
|
592
|
+
}, words.wordOffsets.length, labels, words.wordOffsets, text, threshold);
|
|
593
|
+
}
|
|
594
|
+
/** `( [P] entities ( [E] label1 [E] label2 ) ) [SEP_TEXT]` */
|
|
595
|
+
buildSchema(labels) {
|
|
596
|
+
const config = this.config;
|
|
597
|
+
const tokenize = this.tokenize;
|
|
598
|
+
const open = tokenize(SCHEMA_OPEN);
|
|
599
|
+
const close = tokenize(SCHEMA_CLOSE);
|
|
600
|
+
const tokens = [...open, config.specialTokens[TOKEN_P]];
|
|
601
|
+
tokens.push(...tokenize(NER_TASK_NAME), ...open);
|
|
602
|
+
const labelPositions = [];
|
|
603
|
+
for (const label of labels) {
|
|
604
|
+
labelPositions.push(tokens.length);
|
|
605
|
+
tokens.push(config.specialTokens[TOKEN_E], ...tokenize(label));
|
|
606
|
+
}
|
|
607
|
+
tokens.push(...close, ...close, config.specialTokens[TOKEN_SEP_TEXT]);
|
|
608
|
+
return {
|
|
609
|
+
tokens,
|
|
610
|
+
labelPositions
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Word-split and tokenize the text.
|
|
615
|
+
*
|
|
616
|
+
* Lower-cased before splitting, matching the reference implementation --
|
|
617
|
+
* offsets stay valid because `toLowerCase` is length-preserving for the
|
|
618
|
+
* scripts these models cover.
|
|
619
|
+
*/
|
|
620
|
+
tokenizeWords(text) {
|
|
621
|
+
const tokenize = this.tokenize;
|
|
622
|
+
const tokens = [];
|
|
623
|
+
const wordOffsets = [];
|
|
624
|
+
const firstTokenPositions = [];
|
|
625
|
+
for (const [word, start, end] of splitWords(text.toLowerCase(), RICH_WORD_PATTERN)) {
|
|
626
|
+
wordOffsets.push([start, end]);
|
|
627
|
+
firstTokenPositions.push(tokens.length);
|
|
628
|
+
tokens.push(...tokenize(word));
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
tokens,
|
|
632
|
+
wordOffsets,
|
|
633
|
+
firstTokenPositions
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
async encode(tokens) {
|
|
637
|
+
const { Tensor } = await import("onnxruntime-web");
|
|
638
|
+
const session = this.encoder;
|
|
639
|
+
const seqLen = tokens.length;
|
|
640
|
+
return firstOutput(await session.run({
|
|
641
|
+
input_ids: new Tensor("int64", BigInt64Array.from(tokens, BigInt), [1, seqLen]),
|
|
642
|
+
attention_mask: new Tensor("int64", new BigInt64Array(seqLen).fill(1n), [1, seqLen])
|
|
643
|
+
}), "encoder");
|
|
644
|
+
}
|
|
645
|
+
async runSpanRep(hidden, seqLen, spanStart, spanEnd, spanCount) {
|
|
646
|
+
const { Tensor } = await import("onnxruntime-web");
|
|
647
|
+
const session = this.spanRep;
|
|
648
|
+
const hiddenSize = this.config.hiddenSize;
|
|
649
|
+
return firstOutput(await session.run({
|
|
650
|
+
hidden_states: new Tensor("float32", hidden, [
|
|
651
|
+
1,
|
|
652
|
+
seqLen,
|
|
653
|
+
hiddenSize
|
|
654
|
+
]),
|
|
655
|
+
span_start_idx: new Tensor("int64", BigInt64Array.from(spanStart, BigInt), [1, spanCount]),
|
|
656
|
+
span_end_idx: new Tensor("int64", BigInt64Array.from(spanEnd, BigInt), [1, spanCount])
|
|
657
|
+
}), "span_rep");
|
|
658
|
+
}
|
|
659
|
+
async runCountEmbed(labelEmbeddings, labelCount) {
|
|
660
|
+
const { Tensor } = await import("onnxruntime-web");
|
|
661
|
+
const session = this.countEmbed;
|
|
662
|
+
const hiddenSize = this.config.hiddenSize;
|
|
663
|
+
return firstOutput(await session.run({ label_embeddings: new Tensor("float32", labelEmbeddings, [labelCount, hiddenSize]) }), "count_embed");
|
|
664
|
+
}
|
|
665
|
+
async dispose() {
|
|
666
|
+
await Promise.all([
|
|
667
|
+
this.encoder?.release(),
|
|
668
|
+
this.spanRep?.release(),
|
|
669
|
+
this.countEmbed?.release()
|
|
670
|
+
]);
|
|
671
|
+
this.encoder = null;
|
|
672
|
+
this.spanRep = null;
|
|
673
|
+
this.countEmbed = null;
|
|
674
|
+
this.config = null;
|
|
675
|
+
this.tokenize = null;
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
function firstOutput(outputs, graph) {
|
|
679
|
+
const [name] = Object.keys(outputs);
|
|
680
|
+
if (!name) throw new NerError(`Graph ${graph} produced no output`, "Gliner2Backend");
|
|
681
|
+
return outputs[name].data;
|
|
682
|
+
}
|
|
683
|
+
//#endregion
|
|
684
|
+
//#region src/ner/registry.ts
|
|
685
|
+
/** Generic PII labels, matching the pdftools prototype's default set. */
|
|
686
|
+
const DEFAULT_PII_LABELS = Object.freeze([
|
|
687
|
+
"person",
|
|
688
|
+
"organization",
|
|
689
|
+
"location",
|
|
690
|
+
"email",
|
|
691
|
+
"phone_number",
|
|
692
|
+
"url",
|
|
693
|
+
"id",
|
|
694
|
+
"account_number",
|
|
695
|
+
"zip_code",
|
|
696
|
+
"address",
|
|
697
|
+
"ip_address",
|
|
698
|
+
"date",
|
|
699
|
+
"ssn",
|
|
700
|
+
"driver_license",
|
|
701
|
+
"passport",
|
|
702
|
+
"age",
|
|
703
|
+
"credit_card",
|
|
704
|
+
"medical_condition"
|
|
705
|
+
]);
|
|
706
|
+
/**
|
|
707
|
+
* Label prompts the StabRise GLiNER2 PII model was fine-tuned on.
|
|
708
|
+
*
|
|
709
|
+
* These must match the cloud endpoint's tag list (scaledp-api
|
|
710
|
+
* deidentify/views.py) or scores drop: GLiNER scores a label by its prompt
|
|
711
|
+
* text, so a renamed label is a different label.
|
|
712
|
+
*/
|
|
713
|
+
const GLINER2_PII_LABELS = Object.freeze([
|
|
714
|
+
"date",
|
|
715
|
+
"person_name",
|
|
716
|
+
"person_title",
|
|
717
|
+
"organization",
|
|
718
|
+
"location",
|
|
719
|
+
"email",
|
|
720
|
+
"phone",
|
|
721
|
+
"id",
|
|
722
|
+
"account",
|
|
723
|
+
"zip_code",
|
|
724
|
+
"address",
|
|
725
|
+
"ip",
|
|
726
|
+
"url",
|
|
727
|
+
"ssn",
|
|
728
|
+
"driver_license",
|
|
729
|
+
"passport",
|
|
730
|
+
"age",
|
|
731
|
+
"credit_card",
|
|
732
|
+
"medical_condition",
|
|
733
|
+
"technology"
|
|
734
|
+
]);
|
|
735
|
+
const NER_MODELS = Object.freeze([
|
|
736
|
+
{
|
|
737
|
+
id: "gliner-multi-pii",
|
|
738
|
+
name: "GLiNER multilingual PII, int8 (~333 MB)",
|
|
739
|
+
arch: "gliner1",
|
|
740
|
+
repo: "onnx-community/gliner_multi_pii-v1",
|
|
741
|
+
files: [{
|
|
742
|
+
path: "gliner_config.json",
|
|
743
|
+
approxBytes: 800
|
|
744
|
+
}, {
|
|
745
|
+
path: "onnx/model_int8.onnx",
|
|
746
|
+
approxBytes: 349e6
|
|
747
|
+
}],
|
|
748
|
+
labels: DEFAULT_PII_LABELS,
|
|
749
|
+
languages: ["multi"]
|
|
750
|
+
},
|
|
751
|
+
{
|
|
752
|
+
id: "gliner-small",
|
|
753
|
+
name: "GLiNER small English, int8 (~183 MB)",
|
|
754
|
+
arch: "gliner1",
|
|
755
|
+
repo: "onnx-community/gliner_small-v2.1",
|
|
756
|
+
files: [{
|
|
757
|
+
path: "gliner_config.json",
|
|
758
|
+
approxBytes: 731
|
|
759
|
+
}, {
|
|
760
|
+
path: "onnx/model_quantized.onnx",
|
|
761
|
+
approxBytes: 183403734
|
|
762
|
+
}],
|
|
763
|
+
labels: DEFAULT_PII_LABELS,
|
|
764
|
+
languages: ["en"]
|
|
765
|
+
},
|
|
766
|
+
{
|
|
767
|
+
id: "stabrise-pii-multi",
|
|
768
|
+
name: "StabRise PII multilingual, int8 (~404 MB)",
|
|
769
|
+
arch: "gliner1",
|
|
770
|
+
repo: "StabRise/pii-detection-en-fr-ge-it-es",
|
|
771
|
+
files: [{
|
|
772
|
+
path: "config.json",
|
|
773
|
+
approxBytes: 3417
|
|
774
|
+
}, {
|
|
775
|
+
path: "model_int8.model",
|
|
776
|
+
approxBytes: 403923207
|
|
777
|
+
}],
|
|
778
|
+
labels: DEFAULT_PII_LABELS,
|
|
779
|
+
languages: [
|
|
780
|
+
"en",
|
|
781
|
+
"fr",
|
|
782
|
+
"de",
|
|
783
|
+
"it",
|
|
784
|
+
"es"
|
|
785
|
+
],
|
|
786
|
+
private: true
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
id: "stabrise-pii-multi-g2",
|
|
790
|
+
name: "StabRise PII multilingual GLiNER2, fp32 (~1.2 GB)",
|
|
791
|
+
arch: "gliner2",
|
|
792
|
+
repo: "StabRise/pii-multi-g2-v1-onnx",
|
|
793
|
+
files: [
|
|
794
|
+
{
|
|
795
|
+
path: "config.json",
|
|
796
|
+
approxBytes: 48
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
path: "gliner2_config.json",
|
|
800
|
+
approxBytes: 691
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
path: "onnx/encoder.onnx",
|
|
804
|
+
approxBytes: 1111055946
|
|
805
|
+
},
|
|
806
|
+
{
|
|
807
|
+
path: "onnx/span_rep.onnx",
|
|
808
|
+
approxBytes: 66111424
|
|
809
|
+
},
|
|
810
|
+
{
|
|
811
|
+
path: "onnx/count_embed.onnx",
|
|
812
|
+
approxBytes: 42506885
|
|
813
|
+
}
|
|
814
|
+
],
|
|
815
|
+
labels: GLINER2_PII_LABELS,
|
|
816
|
+
languages: [
|
|
817
|
+
"en",
|
|
818
|
+
"de",
|
|
819
|
+
"pl",
|
|
820
|
+
"es"
|
|
821
|
+
],
|
|
822
|
+
private: true,
|
|
823
|
+
executionProviders: ["wasm"]
|
|
824
|
+
}
|
|
825
|
+
]);
|
|
826
|
+
/** Public, zero-configuration default. */
|
|
827
|
+
const DEFAULT_NER_MODEL_ID = "gliner-multi-pii";
|
|
828
|
+
function getNerModel(id) {
|
|
829
|
+
return NER_MODELS.find((m) => m.id === id);
|
|
830
|
+
}
|
|
831
|
+
/** Total download size in bytes, for a progress estimate before fetching. */
|
|
832
|
+
function modelSizeBytes(model) {
|
|
833
|
+
return model.files.reduce((sum, f) => sum + (f.approxBytes ?? 0), 0);
|
|
834
|
+
}
|
|
835
|
+
//#endregion
|
|
836
|
+
//#region src/ner/tokenizer.ts
|
|
837
|
+
/**
|
|
838
|
+
* Tokenizer loading and adaptation for the GLiNER runtimes.
|
|
839
|
+
*
|
|
840
|
+
* `@huggingface/transformers` provides the tokenizer only; no model runs
|
|
841
|
+
* through it. Its remote host is configurable so gated repos can be proxied
|
|
842
|
+
* through the consuming application's own origin.
|
|
843
|
+
*/
|
|
844
|
+
let modulePromise = null;
|
|
845
|
+
async function loadTransformers() {
|
|
846
|
+
if (modulePromise) return modulePromise;
|
|
847
|
+
modulePromise = (async () => {
|
|
848
|
+
let mod;
|
|
849
|
+
try {
|
|
850
|
+
mod = await import("@huggingface/transformers");
|
|
851
|
+
} catch (cause) {
|
|
852
|
+
throw new Error("@huggingface/transformers is required for NER tokenization. Install it: npm i @huggingface/transformers", { cause });
|
|
853
|
+
}
|
|
854
|
+
const { hf } = getConfig();
|
|
855
|
+
mod.env.allowLocalModels = false;
|
|
856
|
+
if (hf.remoteHost) mod.env.remoteHost = hf.remoteHost;
|
|
857
|
+
if (hf.remotePathTemplate) mod.env.remotePathTemplate = hf.remotePathTemplate;
|
|
858
|
+
return mod;
|
|
859
|
+
})();
|
|
860
|
+
return modulePromise;
|
|
861
|
+
}
|
|
862
|
+
/** Reset the cached module. Tests only. */
|
|
863
|
+
function resetTransformers() {
|
|
864
|
+
modulePromise = null;
|
|
865
|
+
}
|
|
866
|
+
const tokenizers = /* @__PURE__ */ new Map();
|
|
867
|
+
async function loadTokenizer(repo) {
|
|
868
|
+
const existing = tokenizers.get(repo);
|
|
869
|
+
if (existing) return existing;
|
|
870
|
+
const promise = (async () => {
|
|
871
|
+
const { AutoTokenizer } = await loadTransformers();
|
|
872
|
+
return AutoTokenizer.from_pretrained(repo);
|
|
873
|
+
})();
|
|
874
|
+
promise.catch(() => tokenizers.delete(repo));
|
|
875
|
+
tokenizers.set(repo, promise);
|
|
876
|
+
return promise;
|
|
877
|
+
}
|
|
878
|
+
/** Adapt to the callable form the GLiNER2 runtime expects. */
|
|
879
|
+
function toCallableTokenizer(tokenizer) {
|
|
880
|
+
return (text) => {
|
|
881
|
+
return extractTokenIds(tokenizer(text, { add_special_tokens: false }).input_ids.tolist());
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
//#endregion
|
|
885
|
+
//#region src/ner/gliner-ner.ts
|
|
886
|
+
/**
|
|
887
|
+
* Named-entity recognition over a Document, mirroring ScaleDP's `Ner` stage.
|
|
888
|
+
*
|
|
889
|
+
* Chunks long text, runs a GLiNER backend, and maps the resulting character
|
|
890
|
+
* offsets back onto the OCR boxes so every entity carries its position on the
|
|
891
|
+
* page.
|
|
892
|
+
*/
|
|
893
|
+
const GLINER_NER_DEFAULTS = Object.freeze({
|
|
894
|
+
...BASE_STAGE_DEFAULTS,
|
|
895
|
+
inputCol: "text",
|
|
896
|
+
outputCol: "ner",
|
|
897
|
+
keepInputData: true,
|
|
898
|
+
model: DEFAULT_NER_MODEL_ID,
|
|
899
|
+
labels: DEFAULT_PII_LABELS,
|
|
900
|
+
threshold: .5,
|
|
901
|
+
whiteList: [],
|
|
902
|
+
chunkLength: 500,
|
|
903
|
+
chunkStride: 480,
|
|
904
|
+
normaliseCasing: true
|
|
905
|
+
});
|
|
906
|
+
/**
|
|
907
|
+
* Map each character of the joined document text to the box it came from.
|
|
908
|
+
*
|
|
909
|
+
* Built from the *actual* text the OCR stage produced rather than assuming one
|
|
910
|
+
* separator per box. Python derives the mapping from `len(box.text) + 1`, which
|
|
911
|
+
* silently drifts whenever `keepFormatting` inserted several spaces or a
|
|
912
|
+
* newline, shifting every entity's boxes after the first wide gap.
|
|
913
|
+
*/
|
|
914
|
+
function buildCharToBoxMap(text, boxes) {
|
|
915
|
+
const mapping = new Int32Array(text.length).fill(-1);
|
|
916
|
+
let cursor = 0;
|
|
917
|
+
for (const [index, box] of boxes.entries()) {
|
|
918
|
+
if (box.text.length === 0) continue;
|
|
919
|
+
const found = text.indexOf(box.text, cursor);
|
|
920
|
+
if (found === -1) continue;
|
|
921
|
+
mapping.fill(index, found, found + box.text.length);
|
|
922
|
+
cursor = found + box.text.length;
|
|
923
|
+
}
|
|
924
|
+
return mapping;
|
|
925
|
+
}
|
|
926
|
+
/** Boxes a character range touches, in document order and without repeats. */
|
|
927
|
+
function boxesForRange(mapping, boxes, start, end) {
|
|
928
|
+
const seen = /* @__PURE__ */ new Set();
|
|
929
|
+
const out = [];
|
|
930
|
+
for (let i = Math.max(0, start); i < Math.min(end, mapping.length); i++) {
|
|
931
|
+
const index = mapping[i];
|
|
932
|
+
if (index < 0 || seen.has(index)) continue;
|
|
933
|
+
seen.add(index);
|
|
934
|
+
const box = boxes[index];
|
|
935
|
+
if (box) out.push(box);
|
|
936
|
+
}
|
|
937
|
+
return out;
|
|
938
|
+
}
|
|
939
|
+
var GlinerNer = class extends Stage {
|
|
940
|
+
name = "GlinerNer";
|
|
941
|
+
backend = null;
|
|
942
|
+
loading = null;
|
|
943
|
+
constructor(options = {}) {
|
|
944
|
+
super(resolveParams(GLINER_NER_DEFAULTS, options, {
|
|
945
|
+
threshold: (value) => {
|
|
946
|
+
if (!(value >= 0 && value <= 1)) throw new RangeError(`threshold must be between 0 and 1, received ${value}`);
|
|
947
|
+
},
|
|
948
|
+
model: (value) => {
|
|
949
|
+
if (!getNerModel(value)) throw new RangeError(`Unknown NER model "${value}". See NER_MODELS for valid ids.`);
|
|
950
|
+
}
|
|
951
|
+
}));
|
|
952
|
+
}
|
|
953
|
+
async init() {
|
|
954
|
+
await this.getBackend();
|
|
955
|
+
}
|
|
956
|
+
getBackend() {
|
|
957
|
+
if (this.backend) return Promise.resolve(this.backend);
|
|
958
|
+
if (this.loading) return this.loading;
|
|
959
|
+
this.loading = (async () => {
|
|
960
|
+
const model = getNerModel(this.params.model);
|
|
961
|
+
if (!model) throw new NerError(`Unknown model ${this.params.model}`, this.name);
|
|
962
|
+
if (model.private && !getConfig().auth) throw new NerError(`Model ${model.id} lives in a private repo. Supply a token via configure({ auth }).`, this.name);
|
|
963
|
+
const [files, tokenizer] = await Promise.all([ensureModelFiles({
|
|
964
|
+
repo: model.repo,
|
|
965
|
+
files: model.files
|
|
966
|
+
}), loadTokenizer(model.repo)]);
|
|
967
|
+
const backend = model.arch === "gliner2" ? new Gliner2Backend() : new Gliner1Backend();
|
|
968
|
+
await backend.load(files, {
|
|
969
|
+
tokenizer,
|
|
970
|
+
executionProviders: model.executionProviders ?? getConfig().executionProviders
|
|
971
|
+
});
|
|
972
|
+
this.backend = backend;
|
|
973
|
+
return backend;
|
|
974
|
+
})();
|
|
975
|
+
this.loading.catch(() => {
|
|
976
|
+
this.loading = null;
|
|
977
|
+
});
|
|
978
|
+
return this.loading;
|
|
979
|
+
}
|
|
980
|
+
async apply(input, row) {
|
|
981
|
+
const document = input;
|
|
982
|
+
if (!document || typeof document.text !== "string") throw new NerError("Expected a Document with text", this.name);
|
|
983
|
+
if (document.exception) throw new NerError(`Upstream stage failed: ${document.exception}`, this.name);
|
|
984
|
+
const entities = await this.extract(document);
|
|
985
|
+
return createNerOutput({
|
|
986
|
+
path: String(row[this.params.pathCol] ?? document.path ?? "memory"),
|
|
987
|
+
entities,
|
|
988
|
+
json: JSON.stringify(entities)
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
/** Run NER over a document and attach boxes to every entity found. */
|
|
992
|
+
async extract(document) {
|
|
993
|
+
const { labels, threshold, whiteList, chunkLength, chunkStride } = this.params;
|
|
994
|
+
if (document.text.trim().length === 0 || labels.length === 0) return [];
|
|
995
|
+
const backend = await this.getBackend();
|
|
996
|
+
const source = this.params.normaliseCasing ? normaliseCasing(document.text) : document.text;
|
|
997
|
+
const spans = [];
|
|
998
|
+
for (const chunk of chunkText(source, chunkLength, chunkStride)) {
|
|
999
|
+
const found = await backend.extract(chunk.text, labels, threshold);
|
|
1000
|
+
for (const span of found) spans.push(rebaseSpan(span, chunk.offset));
|
|
1001
|
+
}
|
|
1002
|
+
const mapping = buildCharToBoxMap(document.text, document.bboxes);
|
|
1003
|
+
const allowed = new Set(whiteList);
|
|
1004
|
+
return dedupeSpans(spans).filter((span) => allowed.size === 0 || allowed.has(span.label)).map((span) => ({
|
|
1005
|
+
entity_group: span.label,
|
|
1006
|
+
score: span.score,
|
|
1007
|
+
word: document.text.slice(span.start, span.end),
|
|
1008
|
+
start: span.start,
|
|
1009
|
+
end: span.end,
|
|
1010
|
+
boxes: boxesForRange(mapping, document.bboxes, span.start, span.end)
|
|
1011
|
+
}));
|
|
1012
|
+
}
|
|
1013
|
+
onError(message, row) {
|
|
1014
|
+
return createNerOutput({
|
|
1015
|
+
path: String(row[this.params.pathCol] ?? "memory"),
|
|
1016
|
+
exception: message
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
async dispose() {
|
|
1020
|
+
await this.backend?.dispose();
|
|
1021
|
+
this.backend = null;
|
|
1022
|
+
this.loading = null;
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
//#endregion
|
|
1026
|
+
export { isMostlyUppercase as A, toSpanTokenizer as C, DEFAULT_CHUNK_STRIDE as D, DEFAULT_CHUNK_LENGTH as E, rebaseSpan as M, titleCaseAllCapsWords as N, chunkText as O, decodeSpans as S, softmax as T, generateSpans as _, loadTokenizer as a, WORD_PATTERN as b, DEFAULT_NER_MODEL_ID as c, NER_MODELS as d, getNerModel as f, decodeEntities as g, computeDotProductScores as h, buildCharToBoxMap as i, normaliseCasing as j, dedupeSpans as k, DEFAULT_PII_LABELS as l, Gliner2Backend as m, GlinerNer as n, resetTransformers as o, modelSizeBytes as p, boxesForRange as r, toCallableTokenizer as s, GLINER_NER_DEFAULTS as t, GLINER2_PII_LABELS as u, Gliner1Backend as v, sigmoid as w, splitWords as x, RICH_WORD_PATTERN as y };
|
|
1027
|
+
|
|
1028
|
+
//# sourceMappingURL=ner-SsZLZ6ed.js.map
|