gheim 0.1.1 → 0.1.3
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/README.md +40 -19
- package/dist/cjs/index.cjs +289 -111
- package/dist/cjs/openai/index.cjs +313 -106
- package/dist/esm/index.js +296 -113
- package/dist/esm/openai/index.js +318 -106
- package/dist/types/core/session.d.ts +42 -6
- package/dist/types/core/session.d.ts.map +1 -1
- package/dist/types/detectors/composite.d.ts +58 -0
- package/dist/types/detectors/composite.d.ts.map +1 -0
- package/dist/types/detectors/index.d.ts +3 -1
- package/dist/types/detectors/index.d.ts.map +1 -1
- package/dist/types/detectors/local.d.ts +65 -2
- package/dist/types/detectors/local.d.ts.map +1 -1
- package/dist/types/index.d.ts +6 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/normalizers.d.ts +5 -2
- package/dist/types/normalizers.d.ts.map +1 -1
- package/dist/types/openai/_chat.d.ts.map +1 -1
- package/dist/types/openai/_client.d.ts +15 -2
- package/dist/types/openai/_client.d.ts.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -38,33 +38,38 @@ The core package itself has no runtime dependencies.
|
|
|
38
38
|
|
|
39
39
|
`LocalDetector` loads a token-classification model via
|
|
40
40
|
[`@huggingface/transformers`](https://www.npmjs.com/package/@huggingface/transformers)
|
|
41
|
-
(transformers.js).
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
(transformers.js). The package's default model is
|
|
42
|
+
[`joelbarmettler/gheim-ch-560m`](https://huggingface.co/joelbarmettler/gheim-ch-560m)
|
|
43
|
+
— a 560M xlm-roberta-large fine-tune optimised for Swiss-market PII
|
|
44
|
+
(strict-span F1 0.916 on Swiss text, see
|
|
45
|
+
[MODEL_CARD.md](https://github.com/joelbarmettlerUZH/gheim/blob/main/MODEL_CARD.md)).
|
|
46
|
+
Any HuggingFace token-classification model that emits the same 33-class
|
|
47
|
+
BIOES schema can be substituted via `model:`.
|
|
44
48
|
|
|
45
49
|
| Model | Best for | Parameters | Notes |
|
|
46
50
|
|---|---|---:|---|
|
|
47
|
-
| [`joelbarmettler/gheim-ch-560m`](https://huggingface.co/joelbarmettler/gheim-ch-560m) | Swiss-market text (de_CH, fr_CH, it_CH, rm, en) with CH-format account numbers (IBAN, AHV, VAT-CHE) | 560M | Apache 2.0. Test F1
|
|
51
|
+
| [`joelbarmettler/gheim-ch-560m`](https://huggingface.co/joelbarmettler/gheim-ch-560m) **(default)** | Swiss-market text (de_CH, fr_CH, it_CH, rm, en) with CH-format account numbers (IBAN, AHV, VAT-CHE) | 560M | Apache 2.0. Test F1 0.916. Hub repo ships `onnx/model_quantized.onnx` only — load with `dtype: "q8"`. |
|
|
48
52
|
| [`openai/privacy-filter`](https://huggingface.co/openai/privacy-filter) | English-first or general use, long-context (up to 128k tokens) | 1.4B (50M active, MoE) | Apache 2.0. Wider language coverage, larger weights. |
|
|
49
53
|
|
|
50
54
|
```ts
|
|
51
55
|
import { LocalDetector } from "gheim";
|
|
52
56
|
|
|
53
|
-
//
|
|
54
|
-
|
|
57
|
+
// Default — Swiss-tuned, q8 ONNX. `device: "auto"` probes WebGPU in
|
|
58
|
+
// the browser and falls back to WASM; in Node it's WASM directly.
|
|
59
|
+
const det = new LocalDetector({ dtype: "q8" });
|
|
55
60
|
|
|
56
61
|
// Alternative for English or general use:
|
|
57
62
|
const detEn = new LocalDetector({ model: "openai/privacy-filter" });
|
|
58
63
|
```
|
|
59
64
|
|
|
60
|
-
The package default `model` is `openai/privacy-filter`. Pass `model`
|
|
61
|
-
explicitly to opt into the Swiss model.
|
|
62
|
-
|
|
63
65
|
## Drop-in OpenAI client
|
|
64
66
|
|
|
65
67
|
```ts
|
|
66
68
|
import { OpenAI } from "gheim/openai";
|
|
67
69
|
|
|
70
|
+
// Same constructor shape as `openai`. `apiKey`, `baseURL`, etc. work
|
|
71
|
+
// at the top level (forwarded to the inner client). gheim-specific
|
|
72
|
+
// keys: `gheimDetector`, `gheimStrict`, `openaiClient`, `clientOptions`.
|
|
68
73
|
const client = new OpenAI();
|
|
69
74
|
const r = await client.chat.completions.create({
|
|
70
75
|
model: "gpt-4o",
|
|
@@ -74,6 +79,17 @@ const r = await client.chat.completions.create({
|
|
|
74
79
|
// OpenAI only ever saw "<PERSON_1>".
|
|
75
80
|
```
|
|
76
81
|
|
|
82
|
+
Custom endpoint or key (e.g. OpenRouter, local vLLM):
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { OpenAI } from "gheim/openai";
|
|
86
|
+
|
|
87
|
+
const client = new OpenAI({
|
|
88
|
+
apiKey: process.env.OPENROUTER_API_KEY,
|
|
89
|
+
baseURL: "https://openrouter.ai/api/v1",
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
77
93
|
Streaming:
|
|
78
94
|
|
|
79
95
|
```ts
|
|
@@ -104,9 +120,9 @@ All non-chat OpenAI resources are on `client.raw` once `await client.ready()` re
|
|
|
104
120
|
import { Session, LocalDetector, anonymizeText, deanonymizeText } from "gheim";
|
|
105
121
|
|
|
106
122
|
const session = new Session();
|
|
107
|
-
(session as any).detector = new LocalDetector({
|
|
108
|
-
|
|
109
|
-
|
|
123
|
+
(session as any).detector = new LocalDetector({ dtype: "q8" });
|
|
124
|
+
// ^ defaults to joelbarmettler/gheim-ch-560m
|
|
125
|
+
|
|
110
126
|
const clean = await anonymizeText("Hi, my name is Joel", session);
|
|
111
127
|
// ... call any LLM with clean ...
|
|
112
128
|
const final = deanonymizeText(responseText, session);
|
|
@@ -155,12 +171,18 @@ client.raw.beta.assistants.create(...); // always works regardless of strict mo
|
|
|
155
171
|
```ts
|
|
156
172
|
import { LocalDetector, RemoteDetector, defaultDetector } from "gheim";
|
|
157
173
|
|
|
158
|
-
// Local: via @huggingface/transformers, on Node and
|
|
174
|
+
// Local: via @huggingface/transformers, on Node, Bun, and browsers.
|
|
175
|
+
// `device: "auto"` (default) probes navigator.gpu in the browser and
|
|
176
|
+
// falls back to WASM if WebGPU isn't usable; in Node it's WASM only.
|
|
159
177
|
const local = new LocalDetector({
|
|
160
|
-
model: "joelbarmettler/gheim-ch-560m",
|
|
161
178
|
device: "auto",
|
|
162
|
-
dtype: "
|
|
179
|
+
dtype: "q8",
|
|
180
|
+
onProgress: (e) => {
|
|
181
|
+
if (e.fraction != null) console.log(`loading: ${(e.fraction * 100) | 0}%`);
|
|
182
|
+
},
|
|
163
183
|
});
|
|
184
|
+
await local.load();
|
|
185
|
+
console.log("backend in use:", local.actualDevice); // "webgpu" or "wasm"
|
|
164
186
|
|
|
165
187
|
// Remote: against your own gheim-server or api.gheim.ch.
|
|
166
188
|
const remote = new RemoteDetector({
|
|
@@ -174,10 +196,9 @@ const auto = defaultDetector();
|
|
|
174
196
|
|
|
175
197
|
## Runtime support
|
|
176
198
|
|
|
177
|
-
Tested on Node 18
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
considered best-effort.
|
|
199
|
+
Tested on Node 18+, Bun 1.1+, and modern browsers (Chrome 113+, Edge,
|
|
200
|
+
Safari 17+, Firefox via flag). Browser builds use WebGPU when available
|
|
201
|
+
and fall back to WebAssembly automatically.
|
|
181
202
|
|
|
182
203
|
For Node servers, installing `onnxruntime-node` alongside
|
|
183
204
|
`@huggingface/transformers` enables the native ONNX backend, which is
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -41,6 +41,7 @@ var exports_src = {};
|
|
|
41
41
|
__export(exports_src, {
|
|
42
42
|
renderSentinel: () => renderSentinel,
|
|
43
43
|
parseSentinel: () => parseSentinel,
|
|
44
|
+
mergeAdjacent: () => mergeAdjacent,
|
|
44
45
|
labelTag: () => labelTag,
|
|
45
46
|
isPossibleSentinelPrefix: () => isPossibleSentinelPrefix,
|
|
46
47
|
defaultDetector: () => defaultDetector,
|
|
@@ -56,7 +57,8 @@ __export(exports_src, {
|
|
|
56
57
|
REMOTE_DEFAULT_MODEL: () => DEFAULT_MODEL,
|
|
57
58
|
LocalDetector: () => LocalDetector,
|
|
58
59
|
LABEL_TO_TAG: () => LABEL_TO_TAG,
|
|
59
|
-
DEFAULT_BASE_URL: () => DEFAULT_BASE_URL
|
|
60
|
+
DEFAULT_BASE_URL: () => DEFAULT_BASE_URL,
|
|
61
|
+
CompositeDetector: () => CompositeDetector
|
|
60
62
|
});
|
|
61
63
|
module.exports = __toCommonJS(exports_src);
|
|
62
64
|
|
|
@@ -96,98 +98,6 @@ function isPossibleSentinelPrefix(buffer) {
|
|
|
96
98
|
return PARTIAL_SENTINEL_RE.test(buffer);
|
|
97
99
|
}
|
|
98
100
|
|
|
99
|
-
// src/normalizers.ts
|
|
100
|
-
var import_node_module = require("node:module");
|
|
101
|
-
var _require = null;
|
|
102
|
-
var _requireResolved = false;
|
|
103
|
-
function _getRequire() {
|
|
104
|
-
if (_requireResolved)
|
|
105
|
-
return _require;
|
|
106
|
-
_requireResolved = true;
|
|
107
|
-
try {
|
|
108
|
-
_require = import_node_module.createRequire("file:///home/runner/work/gheim/gheim/packages/gheim-js/src/normalizers.ts");
|
|
109
|
-
} catch {
|
|
110
|
-
_require = null;
|
|
111
|
-
}
|
|
112
|
-
return _require;
|
|
113
|
-
}
|
|
114
|
-
function loadSync(name, errMsg) {
|
|
115
|
-
const req = _getRequire();
|
|
116
|
-
if (!req)
|
|
117
|
-
throw new Error(errMsg);
|
|
118
|
-
try {
|
|
119
|
-
return req(name);
|
|
120
|
-
} catch {
|
|
121
|
-
throw new Error(errMsg);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
function loadPhoneLib() {
|
|
125
|
-
return loadSync("libphonenumber-js", "the e164 normalizer requires libphonenumber-js. " + "Install with: bun add libphonenumber-js");
|
|
126
|
-
}
|
|
127
|
-
function e164(opts = {}) {
|
|
128
|
-
const lib = loadPhoneLib();
|
|
129
|
-
return (s) => {
|
|
130
|
-
try {
|
|
131
|
-
const parsed = lib.parsePhoneNumberFromString(s, opts.region);
|
|
132
|
-
if (!parsed || !parsed.isValid())
|
|
133
|
-
return null;
|
|
134
|
-
return parsed.format("E.164");
|
|
135
|
-
} catch {
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
function loadChrono(opts) {
|
|
141
|
-
const mod = loadSync("chrono-node", "the isoDate normalizer requires chrono-node. " + "Install with: bun add chrono-node");
|
|
142
|
-
if (opts.locale) {
|
|
143
|
-
const localeParser = mod[opts.locale];
|
|
144
|
-
if (localeParser)
|
|
145
|
-
return localeParser;
|
|
146
|
-
}
|
|
147
|
-
return mod;
|
|
148
|
-
}
|
|
149
|
-
function isoDate(opts = {}) {
|
|
150
|
-
const parser = loadChrono(opts);
|
|
151
|
-
return (s) => {
|
|
152
|
-
const d = parser.parseDate(s);
|
|
153
|
-
if (!d)
|
|
154
|
-
return null;
|
|
155
|
-
return d.toISOString().slice(0, 10);
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
function germanTransliteration() {
|
|
159
|
-
const table = {
|
|
160
|
-
ä: "ae",
|
|
161
|
-
Ä: "ae",
|
|
162
|
-
ö: "oe",
|
|
163
|
-
Ö: "oe",
|
|
164
|
-
ü: "ue",
|
|
165
|
-
Ü: "ue",
|
|
166
|
-
ß: "ss"
|
|
167
|
-
};
|
|
168
|
-
return (s) => {
|
|
169
|
-
let out = "";
|
|
170
|
-
for (const ch of s.normalize("NFKC")) {
|
|
171
|
-
out += table[ch] ?? ch;
|
|
172
|
-
}
|
|
173
|
-
return out.toLowerCase().split(/\s+/).filter(Boolean).join(" ");
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
var BUILTINS = {
|
|
177
|
-
e164: () => e164(),
|
|
178
|
-
iso_date: () => isoDate(),
|
|
179
|
-
german: () => germanTransliteration()
|
|
180
|
-
};
|
|
181
|
-
function resolveNormalizer(spec) {
|
|
182
|
-
if (typeof spec === "function")
|
|
183
|
-
return spec;
|
|
184
|
-
const factory = BUILTINS[spec];
|
|
185
|
-
if (!factory) {
|
|
186
|
-
throw new Error(`unknown built-in normalizer: ${JSON.stringify(spec)}. ` + `Available: ${Object.keys(BUILTINS).join(", ")}, or pass a callable.`);
|
|
187
|
-
}
|
|
188
|
-
return factory();
|
|
189
|
-
}
|
|
190
|
-
|
|
191
101
|
// src/core/session.ts
|
|
192
102
|
function normalizeSurface(s) {
|
|
193
103
|
return s.normalize("NFKC").toLowerCase().split(/\s+/).filter(Boolean).join(" ");
|
|
@@ -200,8 +110,8 @@ class Session {
|
|
|
200
110
|
resolvedNormalizers = {};
|
|
201
111
|
constructor(opts = {}) {
|
|
202
112
|
if (opts.normalizers) {
|
|
203
|
-
for (const [label,
|
|
204
|
-
this.resolvedNormalizers[label] =
|
|
113
|
+
for (const [label, fn] of Object.entries(opts.normalizers)) {
|
|
114
|
+
this.resolvedNormalizers[label] = fn;
|
|
205
115
|
}
|
|
206
116
|
}
|
|
207
117
|
}
|
|
@@ -225,8 +135,11 @@ class Session {
|
|
|
225
135
|
return sentinel;
|
|
226
136
|
}
|
|
227
137
|
applySpans(text, spans) {
|
|
138
|
+
return this.applySpansDetailed(text, spans).redacted;
|
|
139
|
+
}
|
|
140
|
+
applySpansDetailed(text, spans) {
|
|
228
141
|
if (spans.length === 0)
|
|
229
|
-
return text;
|
|
142
|
+
return { redacted: text, merged: [] };
|
|
230
143
|
const sorted = [...spans].sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start));
|
|
231
144
|
const merged = mergeAdjacent(text, sorted);
|
|
232
145
|
const out = [];
|
|
@@ -242,7 +155,7 @@ class Session {
|
|
|
242
155
|
cursor = end;
|
|
243
156
|
}
|
|
244
157
|
out.push(text.slice(cursor));
|
|
245
|
-
return out.join("");
|
|
158
|
+
return { redacted: out.join(""), merged };
|
|
246
159
|
}
|
|
247
160
|
restore(text) {
|
|
248
161
|
if (Object.keys(this.mapping).length === 0)
|
|
@@ -440,8 +353,10 @@ class LocalDetector {
|
|
|
440
353
|
chunkTokens;
|
|
441
354
|
chunkOverlapTokens;
|
|
442
355
|
batchSize;
|
|
356
|
+
actualDevice = null;
|
|
443
357
|
_pipeline = null;
|
|
444
358
|
_loadPromise = null;
|
|
359
|
+
_onProgress;
|
|
445
360
|
constructor(opts = {}) {
|
|
446
361
|
this.model = opts.model ?? DEFAULT_MODEL2;
|
|
447
362
|
this.device = opts.device ?? "auto";
|
|
@@ -450,11 +365,13 @@ class LocalDetector {
|
|
|
450
365
|
this.chunkTokens = opts.chunkTokens ?? DEFAULT_CHUNK_TOKENS;
|
|
451
366
|
this.chunkOverlapTokens = opts.chunkOverlapTokens ?? DEFAULT_OVERLAP_TOKENS;
|
|
452
367
|
this.batchSize = opts.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
368
|
+
this._onProgress = opts.onProgress;
|
|
453
369
|
if (this.chunkOverlapTokens >= this.chunkTokens) {
|
|
454
370
|
throw new Error(`chunkOverlapTokens (${this.chunkOverlapTokens}) must be smaller than chunkTokens (${this.chunkTokens})`);
|
|
455
371
|
}
|
|
456
372
|
if (opts.pipelineInstance) {
|
|
457
373
|
this._pipeline = opts.pipelineInstance;
|
|
374
|
+
this.actualDevice = this.device === "auto" ? null : this.device;
|
|
458
375
|
}
|
|
459
376
|
}
|
|
460
377
|
async load() {
|
|
@@ -463,24 +380,70 @@ class LocalDetector {
|
|
|
463
380
|
if (this._loadPromise)
|
|
464
381
|
return this._loadPromise;
|
|
465
382
|
this._loadPromise = (async () => {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
383
|
+
const mod = await loadTransformersJs();
|
|
384
|
+
const sequence = await this._planDeviceSequence();
|
|
385
|
+
let lastErr;
|
|
386
|
+
for (let i = 0;i < sequence.length; i++) {
|
|
387
|
+
const dev = sequence[i] ?? "wasm";
|
|
388
|
+
try {
|
|
389
|
+
const pipe = await mod.pipeline("token-classification", this.model, {
|
|
390
|
+
device: dev,
|
|
391
|
+
dtype: this.dtype,
|
|
392
|
+
progress_callback: this._onProgress ? (raw) => this._emitProgress(raw, dev) : undefined
|
|
393
|
+
});
|
|
394
|
+
this._pipeline = pipe;
|
|
395
|
+
this.actualDevice = dev;
|
|
396
|
+
return pipe;
|
|
397
|
+
} catch (e) {
|
|
398
|
+
lastErr = e;
|
|
399
|
+
if (i < sequence.length - 1)
|
|
400
|
+
continue;
|
|
401
|
+
throw e;
|
|
402
|
+
}
|
|
471
403
|
}
|
|
472
|
-
|
|
473
|
-
device: this.device,
|
|
474
|
-
dtype: this.dtype
|
|
475
|
-
});
|
|
476
|
-
this._pipeline = pipe;
|
|
477
|
-
return pipe;
|
|
404
|
+
throw lastErr ?? new Error("LocalDetector.load(): no candidate backends");
|
|
478
405
|
})();
|
|
479
406
|
return this._loadPromise;
|
|
480
407
|
}
|
|
408
|
+
async _planDeviceSequence() {
|
|
409
|
+
if (this.device !== "auto")
|
|
410
|
+
return [this.device];
|
|
411
|
+
const seq = [];
|
|
412
|
+
const nav = typeof navigator !== "undefined" ? navigator : null;
|
|
413
|
+
if (nav?.gpu?.requestAdapter) {
|
|
414
|
+
try {
|
|
415
|
+
const adapter = await nav.gpu.requestAdapter();
|
|
416
|
+
if (adapter)
|
|
417
|
+
seq.push("webgpu");
|
|
418
|
+
} catch {}
|
|
419
|
+
}
|
|
420
|
+
seq.push("wasm");
|
|
421
|
+
return seq;
|
|
422
|
+
}
|
|
423
|
+
_emitProgress(raw, device) {
|
|
424
|
+
if (!this._onProgress)
|
|
425
|
+
return;
|
|
426
|
+
const event = {
|
|
427
|
+
phase: raw.status ?? "progress",
|
|
428
|
+
device
|
|
429
|
+
};
|
|
430
|
+
if (raw.file)
|
|
431
|
+
event.file = raw.file;
|
|
432
|
+
if (raw.status === "progress") {
|
|
433
|
+
if (raw.loaded != null)
|
|
434
|
+
event.loadedBytes = raw.loaded;
|
|
435
|
+
if (raw.total != null)
|
|
436
|
+
event.totalBytes = raw.total;
|
|
437
|
+
if (raw.progress != null) {
|
|
438
|
+
const norm = raw.progress > 1 ? raw.progress / 100 : raw.progress;
|
|
439
|
+
event.fraction = Math.max(0, Math.min(1, norm));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
this._onProgress(event);
|
|
443
|
+
}
|
|
481
444
|
async detect(text, opts) {
|
|
482
445
|
if (opts?.model && opts.model !== this.model) {
|
|
483
|
-
throw new Error(`LocalDetector was constructed with model='${this.model}'; per-call model override ('${opts.model}') not supported.`);
|
|
446
|
+
throw new Error(`LocalDetector was constructed with model='${this.model}'; ` + `per-call model override ('${opts.model}') not supported.`);
|
|
484
447
|
}
|
|
485
448
|
if (!text)
|
|
486
449
|
return [];
|
|
@@ -489,7 +452,7 @@ class LocalDetector {
|
|
|
489
452
|
}
|
|
490
453
|
async detectBatch(texts, opts) {
|
|
491
454
|
if (opts?.model && opts.model !== this.model) {
|
|
492
|
-
throw new Error(`LocalDetector was constructed with model='${this.model}'; per-call model override ('${opts.model}') not supported.`);
|
|
455
|
+
throw new Error(`LocalDetector was constructed with model='${this.model}'; ` + `per-call model override ('${opts.model}') not supported.`);
|
|
493
456
|
}
|
|
494
457
|
if (texts.length === 0)
|
|
495
458
|
return [];
|
|
@@ -650,6 +613,221 @@ function chunkByChars(text, chunkChars, overlapChars) {
|
|
|
650
613
|
function normalizeWord(word) {
|
|
651
614
|
return word.replace(/##/g, "").trim();
|
|
652
615
|
}
|
|
616
|
+
async function loadTransformersJs() {
|
|
617
|
+
try {
|
|
618
|
+
return await import("@huggingface/transformers");
|
|
619
|
+
} catch {
|
|
620
|
+
throw new Error("LocalDetector requires `@huggingface/transformers`. Install with: bun add @huggingface/transformers");
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// src/detectors/composite.ts
|
|
624
|
+
function luhnOk(s) {
|
|
625
|
+
const digits = [];
|
|
626
|
+
for (const c of s) {
|
|
627
|
+
if (c >= "0" && c <= "9")
|
|
628
|
+
digits.push(Number(c));
|
|
629
|
+
}
|
|
630
|
+
if (digits.length < 13 || digits.length > 19)
|
|
631
|
+
return false;
|
|
632
|
+
let total = 0;
|
|
633
|
+
for (let i = 0;i < digits.length; i++) {
|
|
634
|
+
let d = digits[digits.length - 1 - i] ?? 0;
|
|
635
|
+
if (i % 2 === 1) {
|
|
636
|
+
d *= 2;
|
|
637
|
+
if (d > 9)
|
|
638
|
+
d -= 9;
|
|
639
|
+
}
|
|
640
|
+
total += d;
|
|
641
|
+
}
|
|
642
|
+
return total % 10 === 0;
|
|
643
|
+
}
|
|
644
|
+
function ibanChOk(raw) {
|
|
645
|
+
const s = raw.replace(/[\s-]/g, "");
|
|
646
|
+
if (s.length !== 21 || !s.startsWith("CH") || !/^\d+$/.test(s.slice(2))) {
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
const rearranged = s.slice(4) + s.slice(0, 4);
|
|
650
|
+
let numeric = "";
|
|
651
|
+
for (const c of rearranged) {
|
|
652
|
+
numeric += c >= "A" && c <= "Z" ? String(c.charCodeAt(0) - 55) : c;
|
|
653
|
+
}
|
|
654
|
+
let rem = 0;
|
|
655
|
+
for (const ch of numeric) {
|
|
656
|
+
rem = (rem * 10 + Number(ch)) % 97;
|
|
657
|
+
}
|
|
658
|
+
return rem === 1;
|
|
659
|
+
}
|
|
660
|
+
function ahvOk(s) {
|
|
661
|
+
const digits = [];
|
|
662
|
+
for (const c of s) {
|
|
663
|
+
if (c >= "0" && c <= "9")
|
|
664
|
+
digits.push(Number(c));
|
|
665
|
+
}
|
|
666
|
+
if (digits.length !== 13)
|
|
667
|
+
return false;
|
|
668
|
+
if (digits[0] !== 7 || digits[1] !== 5 || digits[2] !== 6)
|
|
669
|
+
return false;
|
|
670
|
+
let total = 0;
|
|
671
|
+
for (let i = 0;i < 12; i++) {
|
|
672
|
+
total += (digits[i] ?? 0) * (i % 2 === 0 ? 1 : 3);
|
|
673
|
+
}
|
|
674
|
+
const check = (10 - total % 10) % 10;
|
|
675
|
+
return digits[12] === check;
|
|
676
|
+
}
|
|
677
|
+
function vatCheOk(s) {
|
|
678
|
+
const digits = [];
|
|
679
|
+
for (const c of s) {
|
|
680
|
+
if (c >= "0" && c <= "9")
|
|
681
|
+
digits.push(Number(c));
|
|
682
|
+
}
|
|
683
|
+
if (digits.length < 9)
|
|
684
|
+
return false;
|
|
685
|
+
const weights = [5, 4, 3, 2, 7, 6, 5, 4];
|
|
686
|
+
let total = 0;
|
|
687
|
+
for (let i = 0;i < 8; i++) {
|
|
688
|
+
total += (digits[i] ?? 0) * (weights[i] ?? 0);
|
|
689
|
+
}
|
|
690
|
+
let expected = 11 - total % 11;
|
|
691
|
+
if (expected === 11)
|
|
692
|
+
expected = 0;
|
|
693
|
+
else if (expected === 10)
|
|
694
|
+
return false;
|
|
695
|
+
return digits[8] === expected;
|
|
696
|
+
}
|
|
697
|
+
var REGEXES = [
|
|
698
|
+
{
|
|
699
|
+
label: "private_email",
|
|
700
|
+
pattern: /\b[\w._%+\-]+@[\w.\-]+\.[A-Za-z]{2,}\b/g
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
label: "private_url",
|
|
704
|
+
pattern: /https?:\/\/[^\s)>\]]+/g
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
label: "private_phone",
|
|
708
|
+
pattern: /(?:\+41|0041|0)\s?(?:\(0\)\s?)?\d{2}[\s\-/.]?\d{3}[\s\-/.]?\d{2}[\s\-/.]?\d{2}\b/g
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
label: "account_number",
|
|
712
|
+
pattern: /\bCH\d{2}(?:[\s-]?\d){17}\b/g,
|
|
713
|
+
validator: ibanChOk
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
label: "account_number",
|
|
717
|
+
pattern: /\b756\.\d{4}\.\d{4}\.\d{2}\b/g,
|
|
718
|
+
validator: ahvOk
|
|
719
|
+
},
|
|
720
|
+
{
|
|
721
|
+
label: "account_number",
|
|
722
|
+
pattern: /\b756\d{10}\b/g,
|
|
723
|
+
validator: ahvOk
|
|
724
|
+
},
|
|
725
|
+
{
|
|
726
|
+
label: "account_number",
|
|
727
|
+
pattern: /\bCHE-\d{3}\.\d{3}\.\d{3}(?:\s+(?:MWST|TVA|IVA))?\b/g,
|
|
728
|
+
validator: vatCheOk
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
label: "account_number",
|
|
732
|
+
pattern: /\b(?:\d[\s-]?){15,16}\d\b/g,
|
|
733
|
+
validator: luhnOk
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
label: "private_date",
|
|
737
|
+
pattern: /\b(?:\d{1,2}[./]\d{1,2}[./]\d{4}|\d{4}[./-]\d{1,2}[./-]\d{1,2})\b/g
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
label: "secret",
|
|
741
|
+
pattern: /\bsk-(?:proj-)?[A-Za-z0-9_\-]{20,}\b/g
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
label: "secret",
|
|
745
|
+
pattern: /\bghp_[A-Za-z0-9]{36}\b/g
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
label: "secret",
|
|
749
|
+
pattern: /\bAKIA[A-Z0-9]{16}\b/g
|
|
750
|
+
}
|
|
751
|
+
];
|
|
752
|
+
var URL_TRAIL_TRIM = '.,;:!?")>]';
|
|
753
|
+
function trimUrl(text, start, end) {
|
|
754
|
+
while (end > start && URL_TRAIL_TRIM.includes(text[end - 1] ?? ""))
|
|
755
|
+
end--;
|
|
756
|
+
return [start, end];
|
|
757
|
+
}
|
|
758
|
+
function findRegexSpans(text) {
|
|
759
|
+
const candidates = [];
|
|
760
|
+
for (const { label, pattern, validator } of REGEXES) {
|
|
761
|
+
pattern.lastIndex = 0;
|
|
762
|
+
let m;
|
|
763
|
+
while ((m = pattern.exec(text)) !== null) {
|
|
764
|
+
let start = m.index;
|
|
765
|
+
let end = m.index + m[0].length;
|
|
766
|
+
if (label === "private_url")
|
|
767
|
+
[start, end] = trimUrl(text, start, end);
|
|
768
|
+
const value = text.slice(start, end);
|
|
769
|
+
if (!value.trim())
|
|
770
|
+
continue;
|
|
771
|
+
if (validator && !validator(value))
|
|
772
|
+
continue;
|
|
773
|
+
candidates.push({
|
|
774
|
+
start,
|
|
775
|
+
end,
|
|
776
|
+
label,
|
|
777
|
+
text: value,
|
|
778
|
+
score: 1
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
candidates.sort((a, b) => a.start !== b.start ? a.start - b.start : b.end - b.start - (a.end - a.start));
|
|
783
|
+
const out = [];
|
|
784
|
+
let lastEnd = -1;
|
|
785
|
+
for (const sp of candidates) {
|
|
786
|
+
if (sp.start < lastEnd)
|
|
787
|
+
continue;
|
|
788
|
+
out.push(sp);
|
|
789
|
+
lastEnd = sp.end;
|
|
790
|
+
}
|
|
791
|
+
return out;
|
|
792
|
+
}
|
|
793
|
+
function maskSpans(text, spans) {
|
|
794
|
+
if (spans.length === 0)
|
|
795
|
+
return text;
|
|
796
|
+
const chars = [...text];
|
|
797
|
+
for (const sp of spans) {
|
|
798
|
+
for (let i = sp.start;i < sp.end && i < chars.length; i++) {
|
|
799
|
+
chars[i] = " ";
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return chars.join("");
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
class CompositeDetector {
|
|
806
|
+
model;
|
|
807
|
+
constructor(opts) {
|
|
808
|
+
if (!opts || !opts.model) {
|
|
809
|
+
throw new Error("CompositeDetector requires { model: Detector } — pass any object with " + "a .detect(text) method (e.g. new LocalDetector({...})).");
|
|
810
|
+
}
|
|
811
|
+
this.model = opts.model;
|
|
812
|
+
}
|
|
813
|
+
async detect(text, opts) {
|
|
814
|
+
if (!text)
|
|
815
|
+
return [];
|
|
816
|
+
const regexSpans = findRegexSpans(text);
|
|
817
|
+
const masked = maskSpans(text, regexSpans);
|
|
818
|
+
const modelSpans = await this.model.detect(masked, opts);
|
|
819
|
+
const out = [...regexSpans];
|
|
820
|
+
const regexRanges = regexSpans.map((s) => [s.start, s.end]);
|
|
821
|
+
for (const sp of modelSpans) {
|
|
822
|
+
const overlaps = regexRanges.some(([rs, re]) => sp.start < re && rs < sp.end);
|
|
823
|
+
if (overlaps)
|
|
824
|
+
continue;
|
|
825
|
+
out.push(sp);
|
|
826
|
+
}
|
|
827
|
+
out.sort((a, b) => a.start !== b.start ? a.start - b.start : b.end - b.start - (a.end - a.start));
|
|
828
|
+
return out;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
653
831
|
// src/detectors/index.ts
|
|
654
832
|
function defaultDetector() {
|
|
655
833
|
const hasKey = typeof process !== "undefined" && Boolean(process.env?.GHEIM_API_KEY);
|
|
@@ -704,4 +882,4 @@ async function* deanonymizeStream(chunks, session) {
|
|
|
704
882
|
}
|
|
705
883
|
|
|
706
884
|
// src/index.ts
|
|
707
|
-
var VERSION = "0.1.
|
|
885
|
+
var VERSION = "0.1.3";
|