gheim 0.1.3 → 0.1.5

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.
@@ -58,7 +58,8 @@ __export(exports_src, {
58
58
  LocalDetector: () => LocalDetector,
59
59
  LABEL_TO_TAG: () => LABEL_TO_TAG,
60
60
  DEFAULT_BASE_URL: () => DEFAULT_BASE_URL,
61
- CompositeDetector: () => CompositeDetector
61
+ CompositeDetector: () => CompositeDetector,
62
+ CalibratedDetector: () => CalibratedDetector
62
63
  });
63
64
  module.exports = __toCommonJS(exports_src);
64
65
 
@@ -155,7 +156,9 @@ class Session {
155
156
  cursor = end;
156
157
  }
157
158
  out.push(text.slice(cursor));
158
- return { redacted: out.join(""), merged };
159
+ let redacted = out.join("");
160
+ redacted = recoverDuplicateOccurrences(redacted, this.mapping);
161
+ return { redacted, merged };
159
162
  }
160
163
  restore(text) {
161
164
  if (Object.keys(this.mapping).length === 0)
@@ -211,6 +214,18 @@ function mergeAdjacent(text, spans) {
211
214
  }
212
215
  return out;
213
216
  }
217
+ function escapeRegex(s) {
218
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
219
+ }
220
+ function recoverDuplicateOccurrences(redacted, mapping) {
221
+ const entries = Object.entries(mapping).filter(([, surface]) => surface && surface.length > 0).sort((a, b) => b[1].length - a[1].length);
222
+ let result = redacted;
223
+ for (const [sentinel, surface] of entries) {
224
+ const pattern = new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegex(surface)}(?![\\p{L}\\p{N}_])`, "gu");
225
+ result = result.replace(pattern, sentinel);
226
+ }
227
+ return result;
228
+ }
214
229
  // src/core/stream.ts
215
230
  var SENTINEL_AT = /<([A-Z][A-Z0-9_]*)_(\d+)>/y;
216
231
  var MAX_SENTINEL_LEN = 64;
@@ -828,10 +843,281 @@ class CompositeDetector {
828
843
  return out;
829
844
  }
830
845
  }
846
+ // src/detectors/calibrated.ts
847
+ var DEFAULT_MODEL3 = "joelbarmettler/gheim-ch-560m";
848
+ var DEFAULT_CHUNK_TOKENS2 = 510;
849
+ var DEFAULT_OVERLAP_TOKENS2 = 32;
850
+ function trimSpan2(text, start, end) {
851
+ while (start < end && /\s/.test(text[start] ?? ""))
852
+ start++;
853
+ while (end > start && /\s/.test(text[end - 1] ?? ""))
854
+ end--;
855
+ return [start, end];
856
+ }
857
+
858
+ class CalibratedDetector {
859
+ model;
860
+ oBias;
861
+ device;
862
+ dtype;
863
+ chunkTokens;
864
+ chunkOverlapTokens;
865
+ trimWhitespace;
866
+ actualDevice = null;
867
+ _model = null;
868
+ _tokenizer = null;
869
+ _id2label = {};
870
+ _oId = 0;
871
+ _loadPromise = null;
872
+ _onProgress;
873
+ constructor(opts = {}) {
874
+ this.model = opts.model ?? DEFAULT_MODEL3;
875
+ this.oBias = opts.oBias ?? 0.5;
876
+ this.device = opts.device ?? "auto";
877
+ this.dtype = opts.dtype ?? "fp32";
878
+ this.chunkTokens = opts.chunkTokens ?? DEFAULT_CHUNK_TOKENS2;
879
+ this.chunkOverlapTokens = opts.chunkOverlapTokens ?? DEFAULT_OVERLAP_TOKENS2;
880
+ this.trimWhitespace = opts.trimWhitespace ?? true;
881
+ this._onProgress = opts.onProgress;
882
+ if (this.chunkOverlapTokens >= this.chunkTokens) {
883
+ throw new Error(`chunkOverlapTokens (${this.chunkOverlapTokens}) must be smaller than ` + `chunkTokens (${this.chunkTokens})`);
884
+ }
885
+ }
886
+ async load() {
887
+ if (this._model)
888
+ return;
889
+ if (this._loadPromise)
890
+ return this._loadPromise;
891
+ this._loadPromise = (async () => {
892
+ const mod = await loadTransformersJs2();
893
+ let preferredDevice = this.device;
894
+ if (this.device === "auto") {
895
+ const nav = typeof navigator !== "undefined" ? navigator : null;
896
+ preferredDevice = "wasm";
897
+ if (nav?.gpu?.requestAdapter) {
898
+ try {
899
+ const adapter = await nav.gpu.requestAdapter();
900
+ if (adapter)
901
+ preferredDevice = "webgpu";
902
+ } catch {}
903
+ }
904
+ }
905
+ const tokenizer = await mod.AutoTokenizer.from_pretrained(this.model);
906
+ const model = await mod.AutoModelForTokenClassification.from_pretrained(this.model, {
907
+ device: preferredDevice,
908
+ dtype: this.dtype,
909
+ progress_callback: this._onProgress ? (raw) => this._emitProgress(raw, preferredDevice) : undefined
910
+ });
911
+ this._tokenizer = tokenizer;
912
+ this._model = model;
913
+ this._id2label = model.config.id2label;
914
+ const label2id = model.config.label2id;
915
+ this._oId = label2id?.["O"] ?? 0;
916
+ this.actualDevice = preferredDevice;
917
+ })();
918
+ return this._loadPromise;
919
+ }
920
+ async detect(text, opts) {
921
+ if (opts?.model && opts.model !== this.model) {
922
+ throw new Error(`CalibratedDetector was constructed with model='${this.model}'; ` + `per-call model override ('${opts.model}') not supported.`);
923
+ }
924
+ if (!text)
925
+ return [];
926
+ await this.load();
927
+ return this._detectChunked(text);
928
+ }
929
+ async _detectChunked(text) {
930
+ if (text.length <= 1500) {
931
+ return await this._detectWindow(text, 0);
932
+ }
933
+ const stride = 1500 - 200;
934
+ const seen = new Set;
935
+ const out = [];
936
+ for (let s = 0;s < text.length; s += stride) {
937
+ const e = Math.min(s + 1500, text.length);
938
+ const sub = text.slice(s, e);
939
+ const subSpans = await this._detectWindow(sub, s);
940
+ for (const sp of subSpans) {
941
+ const key = `${sp.start}|${sp.end}|${sp.label}`;
942
+ if (seen.has(key))
943
+ continue;
944
+ seen.add(key);
945
+ out.push(sp);
946
+ }
947
+ if (e >= text.length)
948
+ break;
949
+ }
950
+ out.sort((a, b) => a.start - b.start || a.end - b.end);
951
+ return out;
952
+ }
953
+ async _detectWindow(text, charOffset) {
954
+ const tokenizer = this._tokenizer;
955
+ const model = this._model;
956
+ const enc = await tokenizer([text], {
957
+ padding: true,
958
+ truncation: true,
959
+ max_length: this.chunkTokens + 2
960
+ });
961
+ const inputs = {};
962
+ if (enc.input_ids)
963
+ inputs["input_ids"] = enc.input_ids;
964
+ if (enc.attention_mask)
965
+ inputs["attention_mask"] = enc.attention_mask;
966
+ const out = await model(inputs);
967
+ const logits = readLogits(out.logits);
968
+ if (this.oBias !== 0) {
969
+ for (let t = 0;t < logits.length; t++) {
970
+ const row = logits[t];
971
+ if (row === undefined)
972
+ continue;
973
+ const cur = row[this._oId];
974
+ if (cur !== undefined) {
975
+ row[this._oId] = cur - this.oBias;
976
+ }
977
+ }
978
+ }
979
+ const inputIds = readInputIds(enc.input_ids);
980
+ const tokenLabels = [];
981
+ for (let t = 0;t < logits.length; t++) {
982
+ const row = logits[t];
983
+ const id = inputIds[t];
984
+ if (!row || id === undefined)
985
+ continue;
986
+ let maxIdx = 0;
987
+ let maxVal = row[0];
988
+ for (let i = 1;i < row.length; i++) {
989
+ if (row[i] > maxVal) {
990
+ maxVal = row[i];
991
+ maxIdx = i;
992
+ }
993
+ }
994
+ tokenLabels.push({ id, label: this._id2label[maxIdx] ?? "O" });
995
+ }
996
+ const spans = [];
997
+ let cursor = 0;
998
+ let groupIds = [];
999
+ let groupCat = null;
1000
+ const flush = () => {
1001
+ if (groupCat === null || groupIds.length === 0)
1002
+ return;
1003
+ const surface = normalizeDecoded(tokenizer.decode(groupIds, { skip_special_tokens: true }));
1004
+ groupIds = [];
1005
+ const cat = groupCat;
1006
+ groupCat = null;
1007
+ if (!surface)
1008
+ return;
1009
+ const idx = text.indexOf(surface, cursor);
1010
+ if (idx === -1)
1011
+ return;
1012
+ let s = idx;
1013
+ let e = idx + surface.length;
1014
+ cursor = e;
1015
+ if (this.trimWhitespace)
1016
+ [s, e] = trimSpan2(text, s, e);
1017
+ if (s >= e)
1018
+ return;
1019
+ spans.push({
1020
+ start: s + charOffset,
1021
+ end: e + charOffset,
1022
+ label: cat,
1023
+ text: text.slice(s, e),
1024
+ score: 1
1025
+ });
1026
+ };
1027
+ for (const { id, label } of tokenLabels) {
1028
+ const cat = label === "O" ? null : label.split("-", 2)[1] ?? null;
1029
+ if (cat === groupCat && cat !== null) {
1030
+ groupIds.push(id);
1031
+ } else {
1032
+ flush();
1033
+ if (cat !== null) {
1034
+ groupIds.push(id);
1035
+ groupCat = cat;
1036
+ }
1037
+ }
1038
+ }
1039
+ flush();
1040
+ return spans;
1041
+ }
1042
+ _emitProgress(raw, device) {
1043
+ if (!this._onProgress)
1044
+ return;
1045
+ const event = {
1046
+ phase: raw.status ?? "progress",
1047
+ device
1048
+ };
1049
+ if (raw.file)
1050
+ event.file = raw.file;
1051
+ if (raw.status === "progress") {
1052
+ if (raw.loaded != null)
1053
+ event.loadedBytes = raw.loaded;
1054
+ if (raw.total != null)
1055
+ event.totalBytes = raw.total;
1056
+ if (raw.progress != null) {
1057
+ const norm = raw.progress > 1 ? raw.progress / 100 : raw.progress;
1058
+ event.fraction = Math.max(0, Math.min(1, norm));
1059
+ }
1060
+ }
1061
+ this._onProgress(event);
1062
+ }
1063
+ }
1064
+ async function loadTransformersJs2() {
1065
+ try {
1066
+ return await import("@huggingface/transformers");
1067
+ } catch {
1068
+ throw new Error("CalibratedDetector requires `@huggingface/transformers`. Install with: bun add @huggingface/transformers");
1069
+ }
1070
+ }
1071
+ function readInputIds(tensor) {
1072
+ if (!tensor)
1073
+ return [];
1074
+ if (typeof tensor.tolist === "function") {
1075
+ const list = tensor.tolist();
1076
+ if (Array.isArray(list) && Array.isArray(list[0])) {
1077
+ return list[0].map((x) => Number(x));
1078
+ }
1079
+ if (Array.isArray(list))
1080
+ return list.map((x) => Number(x));
1081
+ }
1082
+ const dims = tensor.dims;
1083
+ const seq = dims[dims.length - 1] ?? 0;
1084
+ const data = tensor.data;
1085
+ const out = [];
1086
+ for (let i = 0;i < seq; i++)
1087
+ out.push(Number(data[i]));
1088
+ return out;
1089
+ }
1090
+ function normalizeDecoded(s) {
1091
+ return s.replace(/##/g, "").trim();
1092
+ }
1093
+ function readLogits(tensor) {
1094
+ if (typeof tensor.tolist === "function") {
1095
+ const list = tensor.tolist();
1096
+ if (Array.isArray(list) && Array.isArray(list[0]) && Array.isArray(list[0][0])) {
1097
+ return list[0];
1098
+ }
1099
+ if (Array.isArray(list) && Array.isArray(list[0])) {
1100
+ return list;
1101
+ }
1102
+ }
1103
+ const dims = tensor.dims;
1104
+ const seq = dims[dims.length - 2] ?? 0;
1105
+ const cls = dims[dims.length - 1] ?? 0;
1106
+ const data = tensor.data;
1107
+ const out = [];
1108
+ for (let t = 0;t < seq; t++) {
1109
+ const row = [];
1110
+ for (let c = 0;c < cls; c++) {
1111
+ row.push(Number(data[t * cls + c]));
1112
+ }
1113
+ out.push(row);
1114
+ }
1115
+ return out;
1116
+ }
831
1117
  // src/detectors/index.ts
832
1118
  function defaultDetector() {
833
1119
  const hasKey = typeof process !== "undefined" && Boolean(process.env?.GHEIM_API_KEY);
834
- return hasKey ? new RemoteDetector : new LocalDetector;
1120
+ return hasKey ? new RemoteDetector : new CalibratedDetector;
835
1121
  }
836
1122
  // src/plain.ts
837
1123
  function resolve(session, detector) {
@@ -882,4 +1168,4 @@ async function* deanonymizeStream(chunks, session) {
882
1168
  }
883
1169
 
884
1170
  // src/index.ts
885
- var VERSION = "0.1.3";
1171
+ var VERSION = "0.1.5";
@@ -751,10 +751,281 @@ class CompositeDetector {
751
751
  return out;
752
752
  }
753
753
  }
754
+ // src/detectors/calibrated.ts
755
+ var DEFAULT_MODEL3 = "joelbarmettler/gheim-ch-560m";
756
+ var DEFAULT_CHUNK_TOKENS2 = 510;
757
+ var DEFAULT_OVERLAP_TOKENS2 = 32;
758
+ function trimSpan2(text, start, end) {
759
+ while (start < end && /\s/.test(text[start] ?? ""))
760
+ start++;
761
+ while (end > start && /\s/.test(text[end - 1] ?? ""))
762
+ end--;
763
+ return [start, end];
764
+ }
765
+
766
+ class CalibratedDetector {
767
+ model;
768
+ oBias;
769
+ device;
770
+ dtype;
771
+ chunkTokens;
772
+ chunkOverlapTokens;
773
+ trimWhitespace;
774
+ actualDevice = null;
775
+ _model = null;
776
+ _tokenizer = null;
777
+ _id2label = {};
778
+ _oId = 0;
779
+ _loadPromise = null;
780
+ _onProgress;
781
+ constructor(opts = {}) {
782
+ this.model = opts.model ?? DEFAULT_MODEL3;
783
+ this.oBias = opts.oBias ?? 0.5;
784
+ this.device = opts.device ?? "auto";
785
+ this.dtype = opts.dtype ?? "fp32";
786
+ this.chunkTokens = opts.chunkTokens ?? DEFAULT_CHUNK_TOKENS2;
787
+ this.chunkOverlapTokens = opts.chunkOverlapTokens ?? DEFAULT_OVERLAP_TOKENS2;
788
+ this.trimWhitespace = opts.trimWhitespace ?? true;
789
+ this._onProgress = opts.onProgress;
790
+ if (this.chunkOverlapTokens >= this.chunkTokens) {
791
+ throw new Error(`chunkOverlapTokens (${this.chunkOverlapTokens}) must be smaller than ` + `chunkTokens (${this.chunkTokens})`);
792
+ }
793
+ }
794
+ async load() {
795
+ if (this._model)
796
+ return;
797
+ if (this._loadPromise)
798
+ return this._loadPromise;
799
+ this._loadPromise = (async () => {
800
+ const mod = await loadTransformersJs2();
801
+ let preferredDevice = this.device;
802
+ if (this.device === "auto") {
803
+ const nav = typeof navigator !== "undefined" ? navigator : null;
804
+ preferredDevice = "wasm";
805
+ if (nav?.gpu?.requestAdapter) {
806
+ try {
807
+ const adapter = await nav.gpu.requestAdapter();
808
+ if (adapter)
809
+ preferredDevice = "webgpu";
810
+ } catch {}
811
+ }
812
+ }
813
+ const tokenizer = await mod.AutoTokenizer.from_pretrained(this.model);
814
+ const model = await mod.AutoModelForTokenClassification.from_pretrained(this.model, {
815
+ device: preferredDevice,
816
+ dtype: this.dtype,
817
+ progress_callback: this._onProgress ? (raw) => this._emitProgress(raw, preferredDevice) : undefined
818
+ });
819
+ this._tokenizer = tokenizer;
820
+ this._model = model;
821
+ this._id2label = model.config.id2label;
822
+ const label2id = model.config.label2id;
823
+ this._oId = label2id?.["O"] ?? 0;
824
+ this.actualDevice = preferredDevice;
825
+ })();
826
+ return this._loadPromise;
827
+ }
828
+ async detect(text, opts) {
829
+ if (opts?.model && opts.model !== this.model) {
830
+ throw new Error(`CalibratedDetector was constructed with model='${this.model}'; ` + `per-call model override ('${opts.model}') not supported.`);
831
+ }
832
+ if (!text)
833
+ return [];
834
+ await this.load();
835
+ return this._detectChunked(text);
836
+ }
837
+ async _detectChunked(text) {
838
+ if (text.length <= 1500) {
839
+ return await this._detectWindow(text, 0);
840
+ }
841
+ const stride = 1500 - 200;
842
+ const seen = new Set;
843
+ const out = [];
844
+ for (let s = 0;s < text.length; s += stride) {
845
+ const e = Math.min(s + 1500, text.length);
846
+ const sub = text.slice(s, e);
847
+ const subSpans = await this._detectWindow(sub, s);
848
+ for (const sp of subSpans) {
849
+ const key = `${sp.start}|${sp.end}|${sp.label}`;
850
+ if (seen.has(key))
851
+ continue;
852
+ seen.add(key);
853
+ out.push(sp);
854
+ }
855
+ if (e >= text.length)
856
+ break;
857
+ }
858
+ out.sort((a, b) => a.start - b.start || a.end - b.end);
859
+ return out;
860
+ }
861
+ async _detectWindow(text, charOffset) {
862
+ const tokenizer = this._tokenizer;
863
+ const model = this._model;
864
+ const enc = await tokenizer([text], {
865
+ padding: true,
866
+ truncation: true,
867
+ max_length: this.chunkTokens + 2
868
+ });
869
+ const inputs = {};
870
+ if (enc.input_ids)
871
+ inputs["input_ids"] = enc.input_ids;
872
+ if (enc.attention_mask)
873
+ inputs["attention_mask"] = enc.attention_mask;
874
+ const out = await model(inputs);
875
+ const logits = readLogits(out.logits);
876
+ if (this.oBias !== 0) {
877
+ for (let t = 0;t < logits.length; t++) {
878
+ const row = logits[t];
879
+ if (row === undefined)
880
+ continue;
881
+ const cur = row[this._oId];
882
+ if (cur !== undefined) {
883
+ row[this._oId] = cur - this.oBias;
884
+ }
885
+ }
886
+ }
887
+ const inputIds = readInputIds(enc.input_ids);
888
+ const tokenLabels = [];
889
+ for (let t = 0;t < logits.length; t++) {
890
+ const row = logits[t];
891
+ const id = inputIds[t];
892
+ if (!row || id === undefined)
893
+ continue;
894
+ let maxIdx = 0;
895
+ let maxVal = row[0];
896
+ for (let i = 1;i < row.length; i++) {
897
+ if (row[i] > maxVal) {
898
+ maxVal = row[i];
899
+ maxIdx = i;
900
+ }
901
+ }
902
+ tokenLabels.push({ id, label: this._id2label[maxIdx] ?? "O" });
903
+ }
904
+ const spans = [];
905
+ let cursor = 0;
906
+ let groupIds = [];
907
+ let groupCat = null;
908
+ const flush = () => {
909
+ if (groupCat === null || groupIds.length === 0)
910
+ return;
911
+ const surface = normalizeDecoded(tokenizer.decode(groupIds, { skip_special_tokens: true }));
912
+ groupIds = [];
913
+ const cat = groupCat;
914
+ groupCat = null;
915
+ if (!surface)
916
+ return;
917
+ const idx = text.indexOf(surface, cursor);
918
+ if (idx === -1)
919
+ return;
920
+ let s = idx;
921
+ let e = idx + surface.length;
922
+ cursor = e;
923
+ if (this.trimWhitespace)
924
+ [s, e] = trimSpan2(text, s, e);
925
+ if (s >= e)
926
+ return;
927
+ spans.push({
928
+ start: s + charOffset,
929
+ end: e + charOffset,
930
+ label: cat,
931
+ text: text.slice(s, e),
932
+ score: 1
933
+ });
934
+ };
935
+ for (const { id, label } of tokenLabels) {
936
+ const cat = label === "O" ? null : label.split("-", 2)[1] ?? null;
937
+ if (cat === groupCat && cat !== null) {
938
+ groupIds.push(id);
939
+ } else {
940
+ flush();
941
+ if (cat !== null) {
942
+ groupIds.push(id);
943
+ groupCat = cat;
944
+ }
945
+ }
946
+ }
947
+ flush();
948
+ return spans;
949
+ }
950
+ _emitProgress(raw, device) {
951
+ if (!this._onProgress)
952
+ return;
953
+ const event = {
954
+ phase: raw.status ?? "progress",
955
+ device
956
+ };
957
+ if (raw.file)
958
+ event.file = raw.file;
959
+ if (raw.status === "progress") {
960
+ if (raw.loaded != null)
961
+ event.loadedBytes = raw.loaded;
962
+ if (raw.total != null)
963
+ event.totalBytes = raw.total;
964
+ if (raw.progress != null) {
965
+ const norm = raw.progress > 1 ? raw.progress / 100 : raw.progress;
966
+ event.fraction = Math.max(0, Math.min(1, norm));
967
+ }
968
+ }
969
+ this._onProgress(event);
970
+ }
971
+ }
972
+ async function loadTransformersJs2() {
973
+ try {
974
+ return await import("@huggingface/transformers");
975
+ } catch {
976
+ throw new Error("CalibratedDetector requires `@huggingface/transformers`. Install with: bun add @huggingface/transformers");
977
+ }
978
+ }
979
+ function readInputIds(tensor) {
980
+ if (!tensor)
981
+ return [];
982
+ if (typeof tensor.tolist === "function") {
983
+ const list = tensor.tolist();
984
+ if (Array.isArray(list) && Array.isArray(list[0])) {
985
+ return list[0].map((x) => Number(x));
986
+ }
987
+ if (Array.isArray(list))
988
+ return list.map((x) => Number(x));
989
+ }
990
+ const dims = tensor.dims;
991
+ const seq = dims[dims.length - 1] ?? 0;
992
+ const data = tensor.data;
993
+ const out = [];
994
+ for (let i = 0;i < seq; i++)
995
+ out.push(Number(data[i]));
996
+ return out;
997
+ }
998
+ function normalizeDecoded(s) {
999
+ return s.replace(/##/g, "").trim();
1000
+ }
1001
+ function readLogits(tensor) {
1002
+ if (typeof tensor.tolist === "function") {
1003
+ const list = tensor.tolist();
1004
+ if (Array.isArray(list) && Array.isArray(list[0]) && Array.isArray(list[0][0])) {
1005
+ return list[0];
1006
+ }
1007
+ if (Array.isArray(list) && Array.isArray(list[0])) {
1008
+ return list;
1009
+ }
1010
+ }
1011
+ const dims = tensor.dims;
1012
+ const seq = dims[dims.length - 2] ?? 0;
1013
+ const cls = dims[dims.length - 1] ?? 0;
1014
+ const data = tensor.data;
1015
+ const out = [];
1016
+ for (let t = 0;t < seq; t++) {
1017
+ const row = [];
1018
+ for (let c = 0;c < cls; c++) {
1019
+ row.push(Number(data[t * cls + c]));
1020
+ }
1021
+ out.push(row);
1022
+ }
1023
+ return out;
1024
+ }
754
1025
  // src/detectors/index.ts
755
1026
  function defaultDetector() {
756
1027
  const hasKey = typeof process !== "undefined" && Boolean(process.env?.GHEIM_API_KEY);
757
- return hasKey ? new RemoteDetector : new LocalDetector;
1028
+ return hasKey ? new RemoteDetector : new CalibratedDetector;
758
1029
  }
759
1030
 
760
1031
  // src/core/session.ts
@@ -814,7 +1085,9 @@ class Session {
814
1085
  cursor = end;
815
1086
  }
816
1087
  out.push(text.slice(cursor));
817
- return { redacted: out.join(""), merged };
1088
+ let redacted = out.join("");
1089
+ redacted = recoverDuplicateOccurrences(redacted, this.mapping);
1090
+ return { redacted, merged };
818
1091
  }
819
1092
  restore(text) {
820
1093
  if (Object.keys(this.mapping).length === 0)
@@ -870,6 +1143,18 @@ function mergeAdjacent(text, spans) {
870
1143
  }
871
1144
  return out;
872
1145
  }
1146
+ function escapeRegex(s) {
1147
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1148
+ }
1149
+ function recoverDuplicateOccurrences(redacted, mapping) {
1150
+ const entries = Object.entries(mapping).filter(([, surface]) => surface && surface.length > 0).sort((a, b) => b[1].length - a[1].length);
1151
+ let result = redacted;
1152
+ for (const [sentinel, surface] of entries) {
1153
+ const pattern = new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegex(surface)}(?![\\p{L}\\p{N}_])`, "gu");
1154
+ result = result.replace(pattern, sentinel);
1155
+ }
1156
+ return result;
1157
+ }
873
1158
 
874
1159
  // src/openai/_base.ts
875
1160
  function resolveCallSession(body, defaultDet) {