gheim 0.1.8 → 0.1.10

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
@@ -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 local.
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), the Python sibling
225
- package ships a composite detector that pairs the regex catalogue with
226
- the model. The JavaScript package implements the model side; combine
227
- with your own regex layer or proxy through `gheim-server`, which
228
- applies the composite detector internally.
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
 
@@ -39,27 +39,28 @@ var __export = (target, all) => {
39
39
  // src/index.ts
40
40
  var exports_src = {};
41
41
  __export(exports_src, {
42
- renderSentinel: () => renderSentinel,
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
- CalibratedDetector: () => CalibratedDetector
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,10 +322,40 @@ 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";
327
- var DEFAULT_MODEL = "openai/privacy-filter";
358
+ var DEFAULT_MODEL = "joelbarmettler/gheim-ch-560m";
328
359
 
329
360
  class RemoteDetector {
330
361
  baseUrl;
@@ -569,10 +600,7 @@ class LocalDetector {
569
600
  });
570
601
  }
571
602
  }
572
- for (const spans of out) {
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
- const out = [...regexSpans];
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
- tokenLabels.push({ id, label: this._id2label[maxIdx] ?? "O" });
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: 1
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) {