gheim 0.1.8 → 0.1.9
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 +96 -6
- package/dist/cjs/index.cjs +183 -30
- package/dist/cjs/openai/index.cjs +168 -16
- package/dist/esm/index.js +183 -38
- package/dist/esm/openai/index.js +168 -24
- package/dist/types/detectors/base.d.ts +13 -0
- package/dist/types/detectors/base.d.ts.map +1 -1
- package/dist/types/detectors/calibrated.d.ts +0 -28
- package/dist/types/detectors/calibrated.d.ts.map +1 -1
- package/dist/types/detectors/composite.d.ts +50 -0
- package/dist/types/detectors/composite.d.ts.map +1 -1
- package/dist/types/detectors/index.d.ts +10 -6
- package/dist/types/detectors/index.d.ts.map +1 -1
- package/dist/types/detectors/local.d.ts +0 -14
- package/dist/types/detectors/local.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -194,7 +194,8 @@ const remote = new RemoteDetector({
|
|
|
194
194
|
apiKey: "...",
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
-
// Picks remote when GHEIM_API_KEY is set in the environment, else
|
|
197
|
+
// Picks remote when GHEIM_API_KEY is set in the environment, else the composite
|
|
198
|
+
// detector (checksum regex + calibrated local model, see below).
|
|
198
199
|
const auto = defaultDetector();
|
|
199
200
|
```
|
|
200
201
|
|
|
@@ -221,11 +222,100 @@ dist/types/**/*.d.ts
|
|
|
221
222
|
## Composite detector (recommended for production)
|
|
222
223
|
|
|
223
224
|
For categories where structure is verifiable by checksum (CH-IBAN, AHV,
|
|
224
|
-
VAT-CHE, credit cards, common token formats)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
225
|
+
VAT-CHE, credit cards, common token formats) the `CompositeDetector` applies a
|
|
226
|
+
regex catalogue first, masks the matched spans, then runs the model on the
|
|
227
|
+
remainder. `defaultDetector()` returns it (over `CalibratedDetector`) unless
|
|
228
|
+
`GHEIM_API_KEY` is set; use `new CalibratedDetector()` for the model alone.
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import { CalibratedDetector, CompositeDetector } from "gheim";
|
|
232
|
+
|
|
233
|
+
const detector = new CompositeDetector({ model: new CalibratedDetector() });
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`gheim-server` runs the model only, with no regex layer. To add the regex front-end
|
|
237
|
+
and the name handling below to a server, wrap a `RemoteDetector`:
|
|
238
|
+
`new CompositeDetector({ model: new RemoteDetector({ baseUrl }) })`.
|
|
239
|
+
|
|
240
|
+
### Long inputs
|
|
241
|
+
|
|
242
|
+
Inputs longer than the model's 512-token limit are split into overlapping
|
|
243
|
+
windows (`chunkTokens: 510`, `chunkOverlapTokens: 32`) and the spans merged.
|
|
244
|
+
Smaller windows catch more on dense documents but produce more false positives:
|
|
245
|
+
on a Swiss test set of mixed letters and long email threads, 128-token windows
|
|
246
|
+
found noticeably more entities than 510 before repeated-name handling was added,
|
|
247
|
+
at about 40 extra false positives; with it, the gain was small and the false
|
|
248
|
+
positives remained. The default stays at 510; lower it only after measuring on
|
|
249
|
+
your own documents.
|
|
250
|
+
|
|
251
|
+
### Repeated names
|
|
252
|
+
|
|
253
|
+
A token classifier often tags a name once and skips a later occurrence of the
|
|
254
|
+
same string, so a name caught in a greeting leaks again in a signature. The
|
|
255
|
+
composite therefore masks every other occurrence of a string the model found as
|
|
256
|
+
a person (`propagateNames`, on by default). A longer propagated occurrence
|
|
257
|
+
replaces shorter model fragments it contains (`Käthi` inside `Zbinden Käthi`).
|
|
258
|
+
`propagateSurnames: true` also propagates the individual capitalised parts of
|
|
259
|
+
each detected name, honorifics such as "Frau" excluded: higher recall, more
|
|
260
|
+
false positives. `propagateNames: false` turns it off.
|
|
261
|
+
|
|
262
|
+
### Whole words
|
|
263
|
+
|
|
264
|
+
A token classifier sometimes tags only part of a word (`gano` of `Lugano`, `Zbind` +
|
|
265
|
+
`en` of `Zbinden`), and a window boundary can cut a word in half. `LocalDetector` and
|
|
266
|
+
`CalibratedDetector` widen such a span to the whole word and merge same-label spans
|
|
267
|
+
that overlap or touch, so one entity gets one placeholder and nothing readable is
|
|
268
|
+
left behind. A span is widened only when the characters on both sides of its edge
|
|
269
|
+
are word characters; edges at spaces or punctuation (`Gay` in `Gay-Crosier`) are
|
|
270
|
+
left alone.
|
|
271
|
+
|
|
272
|
+
### Confidence
|
|
273
|
+
|
|
274
|
+
`CalibratedDetector` sets each span's `score` to the mean token probability of
|
|
275
|
+
its tokens, computed from the same logits the label decision used. Clear cases
|
|
276
|
+
score near 1.0; ambiguous ones score visibly lower. Regex spans score 1.0.
|
|
277
|
+
|
|
278
|
+
### Your own patterns
|
|
279
|
+
|
|
280
|
+
Formats that only your deployment has (insurance card numbers, hospital record
|
|
281
|
+
numbers, internal case IDs, ...) are not built in. Pass them as `CustomPattern`
|
|
282
|
+
rules; they run in the regex front-end, are masked before the model sees the
|
|
283
|
+
text, and win over a model span they overlap.
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
import { CalibratedDetector, CompositeDetector, CustomPattern } from "gheim";
|
|
287
|
+
|
|
288
|
+
const detector = new CompositeDetector({
|
|
289
|
+
model: new CalibratedDetector(),
|
|
290
|
+
patterns: [
|
|
291
|
+
// whole match is the PII
|
|
292
|
+
new CustomPattern({ label: "insurance_card", regex: /\b80756(?:[ .\-]?\d){15}\b/ }),
|
|
293
|
+
// hospital / case numbers after a label word (DE/FR/IT/EN); reported as
|
|
294
|
+
// account_number, only the digits (group 1) are masked
|
|
295
|
+
new CustomPattern({
|
|
296
|
+
label: "account_number",
|
|
297
|
+
regex:
|
|
298
|
+
/\b(?:PID|FID|IPP|MRN|Patienten-?(?:nummer|nr\.?|id)|Patienten ?nr\.?|Fall-?(?:nummer|nr\.?)|Fall|KG-?Nr\.?|Patient ?ID|case|N° ?(?:patient|de séjour|dossier|IPP)|N\. ?(?:paziente|caso)|numero ?(?:paziente|caso))[ ]?[:#]?[ ]?(\d{6,11})\b/i,
|
|
299
|
+
group: 1,
|
|
300
|
+
}),
|
|
301
|
+
// a label word anchors the match; only group 1 (the ID) is masked.
|
|
302
|
+
// The `i` flag covers the label words; the validator keeps the ID letter upper case.
|
|
303
|
+
new CustomPattern({
|
|
304
|
+
label: "id_document",
|
|
305
|
+
regex: /\b(?:pass|ausweis|id-?karte)(?:[ \-]?(?:nr\.?|nummer))?[ ]?[:#]?[ ]?([A-Za-z]\d{7})\b/i,
|
|
306
|
+
group: 1,
|
|
307
|
+
validator: (id) => /^[A-Z]/.test(id),
|
|
308
|
+
}),
|
|
309
|
+
],
|
|
310
|
+
});
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
`label` becomes the placeholder tag (`insurance_card` -> `<INSURANCE_CARD_1>`), so
|
|
314
|
+
it must start with a letter and contain only letters, digits and underscores;
|
|
315
|
+
reuse a built-in label such as `account_number` to share its tag. A string
|
|
316
|
+
`regex` is compiled with `flags` (default `"i"`); a `RegExp` keeps its own.
|
|
317
|
+
`validator` receives the PII text and can reject a match, for example on a
|
|
318
|
+
checksum.
|
|
229
319
|
|
|
230
320
|
## Consulting
|
|
231
321
|
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -39,27 +39,28 @@ var __export = (target, all) => {
|
|
|
39
39
|
// src/index.ts
|
|
40
40
|
var exports_src = {};
|
|
41
41
|
__export(exports_src, {
|
|
42
|
-
|
|
43
|
-
parseSentinel: () => parseSentinel,
|
|
44
|
-
mergeAdjacent: () => mergeAdjacent,
|
|
45
|
-
labelTag: () => labelTag,
|
|
46
|
-
isPossibleSentinelPrefix: () => isPossibleSentinelPrefix,
|
|
47
|
-
defaultDetector: () => defaultDetector,
|
|
48
|
-
deanonymizeText: () => deanonymizeText,
|
|
49
|
-
deanonymizeStream: () => deanonymizeStream,
|
|
50
|
-
anonymizeText: () => anonymizeText,
|
|
51
|
-
anonymizeMessages: () => anonymizeMessages,
|
|
52
|
-
VERSION: () => VERSION,
|
|
53
|
-
StreamDeanonymizer: () => StreamDeanonymizer,
|
|
54
|
-
Session: () => Session,
|
|
55
|
-
SENTINEL_RE: () => SENTINEL_RE,
|
|
56
|
-
RemoteDetector: () => RemoteDetector,
|
|
57
|
-
REMOTE_DEFAULT_MODEL: () => DEFAULT_MODEL,
|
|
58
|
-
LocalDetector: () => LocalDetector,
|
|
59
|
-
LABEL_TO_TAG: () => LABEL_TO_TAG,
|
|
60
|
-
DEFAULT_BASE_URL: () => DEFAULT_BASE_URL,
|
|
42
|
+
CalibratedDetector: () => CalibratedDetector,
|
|
61
43
|
CompositeDetector: () => CompositeDetector,
|
|
62
|
-
|
|
44
|
+
CustomPattern: () => CustomPattern,
|
|
45
|
+
DEFAULT_BASE_URL: () => DEFAULT_BASE_URL,
|
|
46
|
+
LABEL_TO_TAG: () => LABEL_TO_TAG,
|
|
47
|
+
LocalDetector: () => LocalDetector,
|
|
48
|
+
REMOTE_DEFAULT_MODEL: () => DEFAULT_MODEL,
|
|
49
|
+
RemoteDetector: () => RemoteDetector,
|
|
50
|
+
SENTINEL_RE: () => SENTINEL_RE,
|
|
51
|
+
Session: () => Session,
|
|
52
|
+
StreamDeanonymizer: () => StreamDeanonymizer,
|
|
53
|
+
VERSION: () => VERSION,
|
|
54
|
+
anonymizeMessages: () => anonymizeMessages,
|
|
55
|
+
anonymizeText: () => anonymizeText,
|
|
56
|
+
deanonymizeStream: () => deanonymizeStream,
|
|
57
|
+
deanonymizeText: () => deanonymizeText,
|
|
58
|
+
defaultDetector: () => defaultDetector,
|
|
59
|
+
isPossibleSentinelPrefix: () => isPossibleSentinelPrefix,
|
|
60
|
+
labelTag: () => labelTag,
|
|
61
|
+
mergeAdjacent: () => mergeAdjacent,
|
|
62
|
+
parseSentinel: () => parseSentinel,
|
|
63
|
+
renderSentinel: () => renderSentinel
|
|
63
64
|
});
|
|
64
65
|
module.exports = __toCommonJS(exports_src);
|
|
65
66
|
|
|
@@ -321,6 +322,36 @@ function trimSpan(text, start, end) {
|
|
|
321
322
|
return null;
|
|
322
323
|
return { start, end };
|
|
323
324
|
}
|
|
325
|
+
var WORD_CHAR = /[\p{L}\p{N}_]/u;
|
|
326
|
+
var isWordChar = (ch) => ch !== undefined && WORD_CHAR.test(ch);
|
|
327
|
+
function snapToWords(text, spans) {
|
|
328
|
+
const snapped = spans.map((sp) => {
|
|
329
|
+
let { start, end } = sp;
|
|
330
|
+
while (start > 0 && isWordChar(text[start - 1]) && isWordChar(text[start]))
|
|
331
|
+
start -= 1;
|
|
332
|
+
while (end < text.length && isWordChar(text[end - 1]) && isWordChar(text[end]))
|
|
333
|
+
end += 1;
|
|
334
|
+
return start === sp.start && end === sp.end ? sp : { ...sp, start, end, text: text.slice(start, end) };
|
|
335
|
+
});
|
|
336
|
+
snapped.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
337
|
+
const merged = [];
|
|
338
|
+
const lastByLabel = new Map;
|
|
339
|
+
for (const sp of snapped) {
|
|
340
|
+
const i = lastByLabel.get(sp.label);
|
|
341
|
+
const prev = i === undefined ? undefined : merged[i];
|
|
342
|
+
if (i !== undefined && prev !== undefined && sp.start <= prev.end) {
|
|
343
|
+
const end = Math.max(prev.end, sp.end);
|
|
344
|
+
const wPrev = prev.end - prev.start;
|
|
345
|
+
const wSp = sp.end - sp.start;
|
|
346
|
+
const score = ((prev.score ?? 1) * wPrev + (sp.score ?? 1) * wSp) / (wPrev + wSp);
|
|
347
|
+
merged[i] = { ...prev, end, text: text.slice(prev.start, end), score };
|
|
348
|
+
} else {
|
|
349
|
+
lastByLabel.set(sp.label, merged.length);
|
|
350
|
+
merged.push(sp);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return merged.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
354
|
+
}
|
|
324
355
|
|
|
325
356
|
// src/detectors/remote.ts
|
|
326
357
|
var DEFAULT_BASE_URL = "https://api.gheim.ch";
|
|
@@ -569,10 +600,7 @@ class LocalDetector {
|
|
|
569
600
|
});
|
|
570
601
|
}
|
|
571
602
|
}
|
|
572
|
-
|
|
573
|
-
spans.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
574
|
-
}
|
|
575
|
-
return out;
|
|
603
|
+
return out.map((spans, i) => snapToWords(texts[i] ?? "", spans));
|
|
576
604
|
}
|
|
577
605
|
async chunkText(text, tokenizer) {
|
|
578
606
|
if (tokenizer) {
|
|
@@ -849,21 +877,136 @@ function maskSpans(text, spans) {
|
|
|
849
877
|
return chars.join("");
|
|
850
878
|
}
|
|
851
879
|
|
|
880
|
+
class CustomPattern {
|
|
881
|
+
label;
|
|
882
|
+
group;
|
|
883
|
+
name;
|
|
884
|
+
regex;
|
|
885
|
+
validator;
|
|
886
|
+
constructor(opts) {
|
|
887
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(opts.label)) {
|
|
888
|
+
throw new Error(`CustomPattern label ${JSON.stringify(opts.label)} must start with a letter and contain ` + "only letters, digits and underscores (it becomes a <TAG_1> placeholder).");
|
|
889
|
+
}
|
|
890
|
+
this.label = opts.label;
|
|
891
|
+
this.name = opts.name ?? opts.label;
|
|
892
|
+
this.group = opts.group ?? 0;
|
|
893
|
+
this.validator = opts.validator;
|
|
894
|
+
const source = typeof opts.regex === "string" ? opts.regex : opts.regex.source;
|
|
895
|
+
const base = typeof opts.regex === "string" ? opts.flags ?? "i" : opts.regex.flags;
|
|
896
|
+
const flags = [...new Set([...base, "g", "d"])].join("");
|
|
897
|
+
this.regex = new RegExp(source, flags);
|
|
898
|
+
const groups = (new RegExp(`${source}|`, flags.replace(/[gd]/g, "")).exec("") ?? []).length - 1;
|
|
899
|
+
if (!Number.isInteger(this.group) || this.group < 0 || this.group > groups) {
|
|
900
|
+
throw new Error(`CustomPattern ${JSON.stringify(this.name)}: group ${this.group} does not exist ` + `(the pattern has ${groups}).`);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
find(text) {
|
|
904
|
+
const out = [];
|
|
905
|
+
this.regex.lastIndex = 0;
|
|
906
|
+
let m;
|
|
907
|
+
while ((m = this.regex.exec(text)) !== null) {
|
|
908
|
+
if (m[0] === "") {
|
|
909
|
+
this.regex.lastIndex++;
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
const range = m.indices[this.group];
|
|
913
|
+
if (!range || range[0] === range[1])
|
|
914
|
+
continue;
|
|
915
|
+
const [start, end] = range;
|
|
916
|
+
const value = text.slice(start, end);
|
|
917
|
+
if (this.validator && !this.validator(value))
|
|
918
|
+
continue;
|
|
919
|
+
out.push({ start, end, label: this.label, text: value, score: 1 });
|
|
920
|
+
}
|
|
921
|
+
return out;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
var NAME_PART = /[A-ZÀ-Ý][\p{L}\p{N}_'’-]{3,}/gu;
|
|
925
|
+
var HONORIFICS = new Set([
|
|
926
|
+
"frau",
|
|
927
|
+
"herr",
|
|
928
|
+
"herrn",
|
|
929
|
+
"madame",
|
|
930
|
+
"monsieur",
|
|
931
|
+
"signora",
|
|
932
|
+
"signor",
|
|
933
|
+
"signore",
|
|
934
|
+
"dott",
|
|
935
|
+
"dottor",
|
|
936
|
+
"dottoressa",
|
|
937
|
+
"doktor",
|
|
938
|
+
"docteur",
|
|
939
|
+
"prof",
|
|
940
|
+
"professor",
|
|
941
|
+
"mister",
|
|
942
|
+
"miss",
|
|
943
|
+
"mrs"
|
|
944
|
+
]);
|
|
945
|
+
function escapeRegExp(s) {
|
|
946
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
947
|
+
}
|
|
948
|
+
function propagateNames(text, spans, surnames) {
|
|
949
|
+
const names = new Set;
|
|
950
|
+
for (const sp of spans) {
|
|
951
|
+
if (sp.label !== "private_person")
|
|
952
|
+
continue;
|
|
953
|
+
const value = text.slice(sp.start, sp.end).trim();
|
|
954
|
+
if (value.length >= 4)
|
|
955
|
+
names.add(value);
|
|
956
|
+
if (surnames) {
|
|
957
|
+
for (const part of value.match(NAME_PART) ?? []) {
|
|
958
|
+
if (!HONORIFICS.has(part.toLowerCase()))
|
|
959
|
+
names.add(part);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
let result = [...spans];
|
|
964
|
+
for (const name of [...names].sort((a, b) => b.length - a.length)) {
|
|
965
|
+
const re = new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegExp(name)}(?![\\p{L}\\p{N}_])`, "gu");
|
|
966
|
+
let m;
|
|
967
|
+
while ((m = re.exec(text)) !== null) {
|
|
968
|
+
const start = m.index;
|
|
969
|
+
const end = start + m[0].length;
|
|
970
|
+
const hits = result.filter((sp) => start < sp.end && sp.start < end);
|
|
971
|
+
if (hits.some((sp) => sp.start <= start && end <= sp.end))
|
|
972
|
+
continue;
|
|
973
|
+
if (hits.some((sp) => sp.start < start || end < sp.end))
|
|
974
|
+
continue;
|
|
975
|
+
result = result.filter((sp) => !hits.includes(sp));
|
|
976
|
+
result.push({ start, end, label: "private_person", text: m[0], score: 1 });
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return result;
|
|
980
|
+
}
|
|
981
|
+
|
|
852
982
|
class CompositeDetector {
|
|
853
983
|
model;
|
|
984
|
+
patterns;
|
|
985
|
+
propagateNames;
|
|
986
|
+
propagateSurnames;
|
|
854
987
|
constructor(opts) {
|
|
855
988
|
if (!opts || !opts.model) {
|
|
856
989
|
throw new Error("CompositeDetector requires { model: Detector } — pass any object with " + "a .detect(text) method (e.g. new LocalDetector({...})).");
|
|
857
990
|
}
|
|
858
991
|
this.model = opts.model;
|
|
992
|
+
this.patterns = opts.patterns ?? [];
|
|
993
|
+
this.propagateNames = opts.propagateNames ?? true;
|
|
994
|
+
this.propagateSurnames = opts.propagateSurnames ?? false;
|
|
859
995
|
}
|
|
860
996
|
async detect(text, opts) {
|
|
861
997
|
if (!text)
|
|
862
998
|
return [];
|
|
863
999
|
const regexSpans = findRegexSpans(text);
|
|
1000
|
+
const extra = this.patterns.flatMap((p) => p.find(text));
|
|
1001
|
+
extra.sort((a, b) => a.start !== b.start ? a.start - b.start : b.end - b.start - (a.end - a.start));
|
|
1002
|
+
for (const sp of extra) {
|
|
1003
|
+
if (!regexSpans.some((o) => sp.start < o.end && o.start < sp.end))
|
|
1004
|
+
regexSpans.push(sp);
|
|
1005
|
+
}
|
|
1006
|
+
regexSpans.sort((a, b) => a.start - b.start);
|
|
864
1007
|
const masked = maskSpans(text, regexSpans);
|
|
865
1008
|
const modelSpans = await this.model.detect(masked, opts);
|
|
866
|
-
|
|
1009
|
+
let out = [...regexSpans];
|
|
867
1010
|
const regexRanges = regexSpans.map((s) => [s.start, s.end]);
|
|
868
1011
|
for (const sp of modelSpans) {
|
|
869
1012
|
const overlaps = regexRanges.some(([rs, re]) => sp.start < re && rs < sp.end);
|
|
@@ -871,6 +1014,8 @@ class CompositeDetector {
|
|
|
871
1014
|
continue;
|
|
872
1015
|
out.push(sp);
|
|
873
1016
|
}
|
|
1017
|
+
if (this.propagateNames)
|
|
1018
|
+
out = propagateNames(text, out, this.propagateSurnames);
|
|
874
1019
|
out.sort((a, b) => a.start !== b.start ? a.start - b.start : b.end - b.start - (a.end - a.start));
|
|
875
1020
|
return out;
|
|
876
1021
|
}
|
|
@@ -960,7 +1105,7 @@ class CalibratedDetector {
|
|
|
960
1105
|
if (!text)
|
|
961
1106
|
return [];
|
|
962
1107
|
await this.load();
|
|
963
|
-
return this._detectChunked(text);
|
|
1108
|
+
return snapToWords(text, await this._detectChunked(text));
|
|
964
1109
|
}
|
|
965
1110
|
async _detectChunked(text) {
|
|
966
1111
|
if (text.length <= 1500) {
|
|
@@ -1027,15 +1172,21 @@ class CalibratedDetector {
|
|
|
1027
1172
|
maxIdx = i;
|
|
1028
1173
|
}
|
|
1029
1174
|
}
|
|
1030
|
-
|
|
1175
|
+
let denom = 0;
|
|
1176
|
+
for (let i = 0;i < row.length; i++)
|
|
1177
|
+
denom += Math.exp(row[i] - maxVal);
|
|
1178
|
+
tokenLabels.push({ id, label: this._id2label[maxIdx] ?? "O", prob: 1 / denom });
|
|
1031
1179
|
}
|
|
1032
1180
|
const spans = [];
|
|
1033
1181
|
let cursor = 0;
|
|
1034
1182
|
let groupIds = [];
|
|
1183
|
+
let groupProbs = [];
|
|
1035
1184
|
let groupCat = null;
|
|
1036
1185
|
const flush = () => {
|
|
1037
1186
|
if (groupCat === null || groupIds.length === 0)
|
|
1038
1187
|
return;
|
|
1188
|
+
const score = groupProbs.reduce((a, b) => a + b, 0) / groupProbs.length;
|
|
1189
|
+
groupProbs = [];
|
|
1039
1190
|
const surface = normalizeDecoded(tokenizer.decode(groupIds, { skip_special_tokens: true }));
|
|
1040
1191
|
groupIds = [];
|
|
1041
1192
|
const cat = groupCat;
|
|
@@ -1057,17 +1208,19 @@ class CalibratedDetector {
|
|
|
1057
1208
|
end: e + charOffset,
|
|
1058
1209
|
label: cat,
|
|
1059
1210
|
text: text.slice(s, e),
|
|
1060
|
-
score
|
|
1211
|
+
score
|
|
1061
1212
|
});
|
|
1062
1213
|
};
|
|
1063
|
-
for (const { id, label } of tokenLabels) {
|
|
1214
|
+
for (const { id, label, prob } of tokenLabels) {
|
|
1064
1215
|
const cat = label === "O" ? null : label.split("-", 2)[1] ?? null;
|
|
1065
1216
|
if (cat === groupCat && cat !== null) {
|
|
1066
1217
|
groupIds.push(id);
|
|
1218
|
+
groupProbs.push(prob);
|
|
1067
1219
|
} else {
|
|
1068
1220
|
flush();
|
|
1069
1221
|
if (cat !== null) {
|
|
1070
1222
|
groupIds.push(id);
|
|
1223
|
+
groupProbs.push(prob);
|
|
1071
1224
|
groupCat = cat;
|
|
1072
1225
|
}
|
|
1073
1226
|
}
|
|
@@ -1153,7 +1306,7 @@ function readLogits(tensor) {
|
|
|
1153
1306
|
// src/detectors/index.ts
|
|
1154
1307
|
function defaultDetector() {
|
|
1155
1308
|
const hasKey = typeof process !== "undefined" && Boolean(process.env?.GHEIM_API_KEY);
|
|
1156
|
-
return hasKey ? new RemoteDetector : new CalibratedDetector;
|
|
1309
|
+
return hasKey ? new RemoteDetector : new CompositeDetector({ model: new CalibratedDetector });
|
|
1157
1310
|
}
|
|
1158
1311
|
// src/plain.ts
|
|
1159
1312
|
function resolve(session, detector) {
|
|
@@ -39,13 +39,13 @@ var __export = (target, all) => {
|
|
|
39
39
|
// src/openai/index.ts
|
|
40
40
|
var exports_openai = {};
|
|
41
41
|
__export(exports_openai, {
|
|
42
|
-
|
|
43
|
-
deanonymizeOpenAIResponse: () => deanonymizeOpenAIResponse,
|
|
44
|
-
anonymizeOpenAIMessages: () => anonymizeOpenAIMessages,
|
|
45
|
-
STRICT_REASONS: () => STRICT_REASONS,
|
|
46
|
-
OpenAI: () => OpenAI,
|
|
42
|
+
AsyncOpenAI: () => AsyncOpenAI,
|
|
47
43
|
GheimStrictError: () => GheimStrictError,
|
|
48
|
-
|
|
44
|
+
OpenAI: () => OpenAI,
|
|
45
|
+
STRICT_REASONS: () => STRICT_REASONS,
|
|
46
|
+
anonymizeOpenAIMessages: () => anonymizeOpenAIMessages,
|
|
47
|
+
deanonymizeOpenAIResponse: () => deanonymizeOpenAIResponse,
|
|
48
|
+
deanonymizeOpenAIStream: () => deanonymizeOpenAIStream
|
|
49
49
|
});
|
|
50
50
|
module.exports = __toCommonJS(exports_openai);
|
|
51
51
|
|
|
@@ -207,6 +207,36 @@ function trimSpan(text, start, end) {
|
|
|
207
207
|
return null;
|
|
208
208
|
return { start, end };
|
|
209
209
|
}
|
|
210
|
+
var WORD_CHAR = /[\p{L}\p{N}_]/u;
|
|
211
|
+
var isWordChar = (ch) => ch !== undefined && WORD_CHAR.test(ch);
|
|
212
|
+
function snapToWords(text, spans) {
|
|
213
|
+
const snapped = spans.map((sp) => {
|
|
214
|
+
let { start, end } = sp;
|
|
215
|
+
while (start > 0 && isWordChar(text[start - 1]) && isWordChar(text[start]))
|
|
216
|
+
start -= 1;
|
|
217
|
+
while (end < text.length && isWordChar(text[end - 1]) && isWordChar(text[end]))
|
|
218
|
+
end += 1;
|
|
219
|
+
return start === sp.start && end === sp.end ? sp : { ...sp, start, end, text: text.slice(start, end) };
|
|
220
|
+
});
|
|
221
|
+
snapped.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
222
|
+
const merged = [];
|
|
223
|
+
const lastByLabel = new Map;
|
|
224
|
+
for (const sp of snapped) {
|
|
225
|
+
const i = lastByLabel.get(sp.label);
|
|
226
|
+
const prev = i === undefined ? undefined : merged[i];
|
|
227
|
+
if (i !== undefined && prev !== undefined && sp.start <= prev.end) {
|
|
228
|
+
const end = Math.max(prev.end, sp.end);
|
|
229
|
+
const wPrev = prev.end - prev.start;
|
|
230
|
+
const wSp = sp.end - sp.start;
|
|
231
|
+
const score = ((prev.score ?? 1) * wPrev + (sp.score ?? 1) * wSp) / (wPrev + wSp);
|
|
232
|
+
merged[i] = { ...prev, end, text: text.slice(prev.start, end), score };
|
|
233
|
+
} else {
|
|
234
|
+
lastByLabel.set(sp.label, merged.length);
|
|
235
|
+
merged.push(sp);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return merged.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
239
|
+
}
|
|
210
240
|
|
|
211
241
|
// src/detectors/remote.ts
|
|
212
242
|
var DEFAULT_BASE_URL = "https://api.gheim.ch";
|
|
@@ -455,10 +485,7 @@ class LocalDetector {
|
|
|
455
485
|
});
|
|
456
486
|
}
|
|
457
487
|
}
|
|
458
|
-
|
|
459
|
-
spans.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
460
|
-
}
|
|
461
|
-
return out;
|
|
488
|
+
return out.map((spans, i) => snapToWords(texts[i] ?? "", spans));
|
|
462
489
|
}
|
|
463
490
|
async chunkText(text, tokenizer) {
|
|
464
491
|
if (tokenizer) {
|
|
@@ -735,21 +762,136 @@ function maskSpans(text, spans) {
|
|
|
735
762
|
return chars.join("");
|
|
736
763
|
}
|
|
737
764
|
|
|
765
|
+
class CustomPattern {
|
|
766
|
+
label;
|
|
767
|
+
group;
|
|
768
|
+
name;
|
|
769
|
+
regex;
|
|
770
|
+
validator;
|
|
771
|
+
constructor(opts) {
|
|
772
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(opts.label)) {
|
|
773
|
+
throw new Error(`CustomPattern label ${JSON.stringify(opts.label)} must start with a letter and contain ` + "only letters, digits and underscores (it becomes a <TAG_1> placeholder).");
|
|
774
|
+
}
|
|
775
|
+
this.label = opts.label;
|
|
776
|
+
this.name = opts.name ?? opts.label;
|
|
777
|
+
this.group = opts.group ?? 0;
|
|
778
|
+
this.validator = opts.validator;
|
|
779
|
+
const source = typeof opts.regex === "string" ? opts.regex : opts.regex.source;
|
|
780
|
+
const base = typeof opts.regex === "string" ? opts.flags ?? "i" : opts.regex.flags;
|
|
781
|
+
const flags = [...new Set([...base, "g", "d"])].join("");
|
|
782
|
+
this.regex = new RegExp(source, flags);
|
|
783
|
+
const groups = (new RegExp(`${source}|`, flags.replace(/[gd]/g, "")).exec("") ?? []).length - 1;
|
|
784
|
+
if (!Number.isInteger(this.group) || this.group < 0 || this.group > groups) {
|
|
785
|
+
throw new Error(`CustomPattern ${JSON.stringify(this.name)}: group ${this.group} does not exist ` + `(the pattern has ${groups}).`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
find(text) {
|
|
789
|
+
const out = [];
|
|
790
|
+
this.regex.lastIndex = 0;
|
|
791
|
+
let m;
|
|
792
|
+
while ((m = this.regex.exec(text)) !== null) {
|
|
793
|
+
if (m[0] === "") {
|
|
794
|
+
this.regex.lastIndex++;
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
const range = m.indices[this.group];
|
|
798
|
+
if (!range || range[0] === range[1])
|
|
799
|
+
continue;
|
|
800
|
+
const [start, end] = range;
|
|
801
|
+
const value = text.slice(start, end);
|
|
802
|
+
if (this.validator && !this.validator(value))
|
|
803
|
+
continue;
|
|
804
|
+
out.push({ start, end, label: this.label, text: value, score: 1 });
|
|
805
|
+
}
|
|
806
|
+
return out;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
var NAME_PART = /[A-ZÀ-Ý][\p{L}\p{N}_'’-]{3,}/gu;
|
|
810
|
+
var HONORIFICS = new Set([
|
|
811
|
+
"frau",
|
|
812
|
+
"herr",
|
|
813
|
+
"herrn",
|
|
814
|
+
"madame",
|
|
815
|
+
"monsieur",
|
|
816
|
+
"signora",
|
|
817
|
+
"signor",
|
|
818
|
+
"signore",
|
|
819
|
+
"dott",
|
|
820
|
+
"dottor",
|
|
821
|
+
"dottoressa",
|
|
822
|
+
"doktor",
|
|
823
|
+
"docteur",
|
|
824
|
+
"prof",
|
|
825
|
+
"professor",
|
|
826
|
+
"mister",
|
|
827
|
+
"miss",
|
|
828
|
+
"mrs"
|
|
829
|
+
]);
|
|
830
|
+
function escapeRegExp(s) {
|
|
831
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
832
|
+
}
|
|
833
|
+
function propagateNames(text, spans, surnames) {
|
|
834
|
+
const names = new Set;
|
|
835
|
+
for (const sp of spans) {
|
|
836
|
+
if (sp.label !== "private_person")
|
|
837
|
+
continue;
|
|
838
|
+
const value = text.slice(sp.start, sp.end).trim();
|
|
839
|
+
if (value.length >= 4)
|
|
840
|
+
names.add(value);
|
|
841
|
+
if (surnames) {
|
|
842
|
+
for (const part of value.match(NAME_PART) ?? []) {
|
|
843
|
+
if (!HONORIFICS.has(part.toLowerCase()))
|
|
844
|
+
names.add(part);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
let result = [...spans];
|
|
849
|
+
for (const name of [...names].sort((a, b) => b.length - a.length)) {
|
|
850
|
+
const re = new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegExp(name)}(?![\\p{L}\\p{N}_])`, "gu");
|
|
851
|
+
let m;
|
|
852
|
+
while ((m = re.exec(text)) !== null) {
|
|
853
|
+
const start = m.index;
|
|
854
|
+
const end = start + m[0].length;
|
|
855
|
+
const hits = result.filter((sp) => start < sp.end && sp.start < end);
|
|
856
|
+
if (hits.some((sp) => sp.start <= start && end <= sp.end))
|
|
857
|
+
continue;
|
|
858
|
+
if (hits.some((sp) => sp.start < start || end < sp.end))
|
|
859
|
+
continue;
|
|
860
|
+
result = result.filter((sp) => !hits.includes(sp));
|
|
861
|
+
result.push({ start, end, label: "private_person", text: m[0], score: 1 });
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return result;
|
|
865
|
+
}
|
|
866
|
+
|
|
738
867
|
class CompositeDetector {
|
|
739
868
|
model;
|
|
869
|
+
patterns;
|
|
870
|
+
propagateNames;
|
|
871
|
+
propagateSurnames;
|
|
740
872
|
constructor(opts) {
|
|
741
873
|
if (!opts || !opts.model) {
|
|
742
874
|
throw new Error("CompositeDetector requires { model: Detector } — pass any object with " + "a .detect(text) method (e.g. new LocalDetector({...})).");
|
|
743
875
|
}
|
|
744
876
|
this.model = opts.model;
|
|
877
|
+
this.patterns = opts.patterns ?? [];
|
|
878
|
+
this.propagateNames = opts.propagateNames ?? true;
|
|
879
|
+
this.propagateSurnames = opts.propagateSurnames ?? false;
|
|
745
880
|
}
|
|
746
881
|
async detect(text, opts) {
|
|
747
882
|
if (!text)
|
|
748
883
|
return [];
|
|
749
884
|
const regexSpans = findRegexSpans(text);
|
|
885
|
+
const extra = this.patterns.flatMap((p) => p.find(text));
|
|
886
|
+
extra.sort((a, b) => a.start !== b.start ? a.start - b.start : b.end - b.start - (a.end - a.start));
|
|
887
|
+
for (const sp of extra) {
|
|
888
|
+
if (!regexSpans.some((o) => sp.start < o.end && o.start < sp.end))
|
|
889
|
+
regexSpans.push(sp);
|
|
890
|
+
}
|
|
891
|
+
regexSpans.sort((a, b) => a.start - b.start);
|
|
750
892
|
const masked = maskSpans(text, regexSpans);
|
|
751
893
|
const modelSpans = await this.model.detect(masked, opts);
|
|
752
|
-
|
|
894
|
+
let out = [...regexSpans];
|
|
753
895
|
const regexRanges = regexSpans.map((s) => [s.start, s.end]);
|
|
754
896
|
for (const sp of modelSpans) {
|
|
755
897
|
const overlaps = regexRanges.some(([rs, re]) => sp.start < re && rs < sp.end);
|
|
@@ -757,6 +899,8 @@ class CompositeDetector {
|
|
|
757
899
|
continue;
|
|
758
900
|
out.push(sp);
|
|
759
901
|
}
|
|
902
|
+
if (this.propagateNames)
|
|
903
|
+
out = propagateNames(text, out, this.propagateSurnames);
|
|
760
904
|
out.sort((a, b) => a.start !== b.start ? a.start - b.start : b.end - b.start - (a.end - a.start));
|
|
761
905
|
return out;
|
|
762
906
|
}
|
|
@@ -846,7 +990,7 @@ class CalibratedDetector {
|
|
|
846
990
|
if (!text)
|
|
847
991
|
return [];
|
|
848
992
|
await this.load();
|
|
849
|
-
return this._detectChunked(text);
|
|
993
|
+
return snapToWords(text, await this._detectChunked(text));
|
|
850
994
|
}
|
|
851
995
|
async _detectChunked(text) {
|
|
852
996
|
if (text.length <= 1500) {
|
|
@@ -913,15 +1057,21 @@ class CalibratedDetector {
|
|
|
913
1057
|
maxIdx = i;
|
|
914
1058
|
}
|
|
915
1059
|
}
|
|
916
|
-
|
|
1060
|
+
let denom = 0;
|
|
1061
|
+
for (let i = 0;i < row.length; i++)
|
|
1062
|
+
denom += Math.exp(row[i] - maxVal);
|
|
1063
|
+
tokenLabels.push({ id, label: this._id2label[maxIdx] ?? "O", prob: 1 / denom });
|
|
917
1064
|
}
|
|
918
1065
|
const spans = [];
|
|
919
1066
|
let cursor = 0;
|
|
920
1067
|
let groupIds = [];
|
|
1068
|
+
let groupProbs = [];
|
|
921
1069
|
let groupCat = null;
|
|
922
1070
|
const flush = () => {
|
|
923
1071
|
if (groupCat === null || groupIds.length === 0)
|
|
924
1072
|
return;
|
|
1073
|
+
const score = groupProbs.reduce((a, b) => a + b, 0) / groupProbs.length;
|
|
1074
|
+
groupProbs = [];
|
|
925
1075
|
const surface = normalizeDecoded(tokenizer.decode(groupIds, { skip_special_tokens: true }));
|
|
926
1076
|
groupIds = [];
|
|
927
1077
|
const cat = groupCat;
|
|
@@ -943,17 +1093,19 @@ class CalibratedDetector {
|
|
|
943
1093
|
end: e + charOffset,
|
|
944
1094
|
label: cat,
|
|
945
1095
|
text: text.slice(s, e),
|
|
946
|
-
score
|
|
1096
|
+
score
|
|
947
1097
|
});
|
|
948
1098
|
};
|
|
949
|
-
for (const { id, label } of tokenLabels) {
|
|
1099
|
+
for (const { id, label, prob } of tokenLabels) {
|
|
950
1100
|
const cat = label === "O" ? null : label.split("-", 2)[1] ?? null;
|
|
951
1101
|
if (cat === groupCat && cat !== null) {
|
|
952
1102
|
groupIds.push(id);
|
|
1103
|
+
groupProbs.push(prob);
|
|
953
1104
|
} else {
|
|
954
1105
|
flush();
|
|
955
1106
|
if (cat !== null) {
|
|
956
1107
|
groupIds.push(id);
|
|
1108
|
+
groupProbs.push(prob);
|
|
957
1109
|
groupCat = cat;
|
|
958
1110
|
}
|
|
959
1111
|
}
|
|
@@ -1039,7 +1191,7 @@ function readLogits(tensor) {
|
|
|
1039
1191
|
// src/detectors/index.ts
|
|
1040
1192
|
function defaultDetector() {
|
|
1041
1193
|
const hasKey = typeof process !== "undefined" && Boolean(process.env?.GHEIM_API_KEY);
|
|
1042
|
-
return hasKey ? new RemoteDetector : new CalibratedDetector;
|
|
1194
|
+
return hasKey ? new RemoteDetector : new CompositeDetector({ model: new CalibratedDetector });
|
|
1043
1195
|
}
|
|
1044
1196
|
|
|
1045
1197
|
// src/core/session.ts
|