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 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). Two models are recommended depending on the
42
- deployment context. Both implement the same 33-class BIOES output
43
- schema and are interchangeable.
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 = 0.916 on Swiss text. ONNX export ships in the `onnx/` subfolder. |
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
- // Recommended for Swiss-market text:
54
- const det = new LocalDetector({ model: "joelbarmettler/gheim-ch-560m" });
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
- model: "joelbarmettler/gheim-ch-560m",
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 Bun.
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: "fp32",
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+ and Bun 1.1+ with `device: "cpu"` (the default). The
178
- underlying transformers.js library also supports `device: "webgpu"` in
179
- modern browsers; the WebGPU path is not exercised in CI and should be
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
@@ -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, spec] of Object.entries(opts.normalizers)) {
204
- this.resolvedNormalizers[label] = resolveNormalizer(spec);
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
- let mod;
467
- try {
468
- mod = await import("@huggingface/transformers");
469
- } catch (e) {
470
- throw new Error("LocalDetector requires `@huggingface/transformers`. Install with: bun add @huggingface/transformers");
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
- const pipe = await mod.pipeline("token-classification", this.model, {
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.0";
885
+ var VERSION = "0.1.3";