@stll/anonymize-wasm 1.4.7 → 1.4.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/dist/wasm.mjs CHANGED
@@ -33,6 +33,10 @@ const isLegalFormsEnabled = (config) => config.enableLegalForms !== false;
33
33
  * shallow copies (spread). Uses position + label so the
34
34
  * key is identical for the original object and any
35
35
  * `{ ...entity }` copy produced by mergeAndDedup.
36
+ *
37
+ * @deprecated No longer used internally: coref alias
38
+ * links travel on the entities themselves
39
+ * (`corefSourceText`). Kept for API compatibility.
36
40
  */
37
41
  const corefKey = (e) => `${e.start}:${e.end}:${e.label}`;
38
42
  /** Create a fresh, empty pipeline context. */
@@ -62,8 +66,7 @@ const createPipelineContext = () => ({
62
66
  roleStopSetPromise: null,
63
67
  zoneHeadingPatterns: null,
64
68
  zoneSigningPatterns: null,
65
- zoneInitPromise: null,
66
- corefSourceMap: /* @__PURE__ */ new Map()
69
+ zoneInitPromise: null
67
70
  });
68
71
  /**
69
72
  * Module-level default context. Used when callers
@@ -1761,11 +1764,14 @@ const isWordChar = (ch) => {
1761
1764
  * character before the start and after the end are NOT
1762
1765
  * word characters (letter/digit).
1763
1766
  *
1764
- * Populates `ctx.corefSourceMap` with entries linking
1765
- * each coref entity to its source entity text, for
1766
- * consistent placeholder numbering.
1767
+ * Each returned alias carries `corefSourceText` linking
1768
+ * it to its source entity text, for consistent
1769
+ * placeholder numbering.
1770
+ *
1771
+ * @param _ctx Unused. Kept for signature compatibility;
1772
+ * alias links now travel on the entities themselves.
1767
1773
  */
1768
- const findCoreferenceSpans = (fullText, terms, ctx = defaultContext) => {
1774
+ const findCoreferenceSpans = (fullText, terms, _ctx = defaultContext) => {
1769
1775
  const results = [];
1770
1776
  for (const term of terms) {
1771
1777
  let searchFrom = 0;
@@ -1779,16 +1785,15 @@ const findCoreferenceSpans = (fullText, terms, ctx = defaultContext) => {
1779
1785
  searchFrom = idx + 1;
1780
1786
  continue;
1781
1787
  }
1782
- const entity = {
1788
+ results.push({
1783
1789
  start: idx,
1784
1790
  end: matchEnd,
1785
1791
  label: term.label,
1786
1792
  text: term.alias,
1787
1793
  score: .95,
1788
- source: DETECTION_SOURCES.COREFERENCE
1789
- };
1790
- ctx.corefSourceMap.set(corefKey(entity), term.sourceText);
1791
- results.push(entity);
1794
+ source: DETECTION_SOURCES.COREFERENCE,
1795
+ corefSourceText: term.sourceText
1796
+ });
1792
1797
  searchFrom = matchEnd;
1793
1798
  }
1794
1799
  }
@@ -8055,32 +8060,216 @@ const initNameCorpus = (ctx = defaultContext, dictionaries, languages) => {
8055
8060
  ctx.nameCorpusPromise = promise;
8056
8061
  return promise;
8057
8062
  };
8058
- const INFLECTION_SUFFIXES = [
8059
- "ovi",
8060
- "em",
8061
- "om",
8062
- "ou",
8063
- "é",
8064
- "a",
8065
- "u"
8063
+ const DECLENSION_RULES = [
8064
+ {
8065
+ ending: "",
8066
+ gate: /[bdfghklmnpqrstvwxzł]$/u,
8067
+ forms: [
8068
+ "a",
8069
+ "u",
8070
+ "ovi",
8071
+ "em",
8072
+ "om"
8073
+ ]
8074
+ },
8075
+ {
8076
+ ending: "",
8077
+ gate: /[bdflmnpstvwz]$/u,
8078
+ forms: ["e"]
8079
+ },
8080
+ {
8081
+ ending: "",
8082
+ gate: /[cčďťňřšžjľ]$/u,
8083
+ forms: [
8084
+ "e",
8085
+ "i",
8086
+ "a",
8087
+ "ovi",
8088
+ "em",
8089
+ "om"
8090
+ ]
8091
+ },
8092
+ {
8093
+ ending: "ek",
8094
+ gate: /[^aeiouyáéěíóôúůý]ek$/u,
8095
+ forms: [
8096
+ "ka",
8097
+ "ku",
8098
+ "kovi",
8099
+ "kem",
8100
+ "kom"
8101
+ ]
8102
+ },
8103
+ {
8104
+ ending: "el",
8105
+ gate: /[^aeiouyáéěíóôúůý]el$/u,
8106
+ forms: [
8107
+ "la",
8108
+ "lu",
8109
+ "le",
8110
+ "lovi",
8111
+ "lem",
8112
+ "lom"
8113
+ ]
8114
+ },
8115
+ {
8116
+ ending: "ec",
8117
+ gate: /[^aeiouyáéěíóôúůý]ec$/u,
8118
+ forms: [
8119
+ "ce",
8120
+ "ci",
8121
+ "covi",
8122
+ "cem",
8123
+ "com"
8124
+ ]
8125
+ },
8126
+ {
8127
+ ending: "a",
8128
+ gate: /[^cčďťňřšžji]a$/u,
8129
+ forms: [
8130
+ "y",
8131
+ "u",
8132
+ "o",
8133
+ "ou",
8134
+ "ovi"
8135
+ ]
8136
+ },
8137
+ {
8138
+ ending: "a",
8139
+ gate: /[cčďťňřšžj]a$/u,
8140
+ forms: [
8141
+ "i",
8142
+ "u",
8143
+ "o",
8144
+ "ou",
8145
+ "ovi"
8146
+ ]
8147
+ },
8148
+ {
8149
+ ending: "a",
8150
+ gate: /ia$/u,
8151
+ forms: [
8152
+ "e",
8153
+ "i",
8154
+ "u",
8155
+ "ou"
8156
+ ]
8157
+ },
8158
+ {
8159
+ ending: "ka",
8160
+ gate: /ka$/u,
8161
+ forms: ["ce"]
8162
+ },
8163
+ {
8164
+ ending: "ra",
8165
+ gate: /ra$/u,
8166
+ forms: ["ře", "re"]
8167
+ },
8168
+ {
8169
+ ending: "ha",
8170
+ gate: /ha$/u,
8171
+ forms: ["ze"]
8172
+ },
8173
+ {
8174
+ ending: "ga",
8175
+ gate: /ga$/u,
8176
+ forms: ["ze"]
8177
+ },
8178
+ {
8179
+ ending: "cha",
8180
+ gate: /cha$/u,
8181
+ forms: ["še"]
8182
+ },
8183
+ {
8184
+ ending: "a",
8185
+ gate: /[bdfmnptv]a$/u,
8186
+ forms: ["ě", "e"]
8187
+ },
8188
+ {
8189
+ ending: "a",
8190
+ gate: /[szl]a$/u,
8191
+ forms: ["e"]
8192
+ },
8193
+ {
8194
+ ending: "á",
8195
+ gate: /á$/u,
8196
+ forms: [
8197
+ "é",
8198
+ "ou",
8199
+ "ej",
8200
+ "ú"
8201
+ ]
8202
+ },
8203
+ {
8204
+ ending: "ý",
8205
+ gate: /ý$/u,
8206
+ forms: [
8207
+ "ého",
8208
+ "ému",
8209
+ "ém",
8210
+ "ým"
8211
+ ]
8212
+ },
8213
+ {
8214
+ ending: "",
8215
+ gate: /[íiy]$/u,
8216
+ forms: [
8217
+ "ho",
8218
+ "mu",
8219
+ "m"
8220
+ ]
8221
+ },
8222
+ {
8223
+ ending: "e",
8224
+ gate: /[^aeouyáéěíóôúůý]e$/u,
8225
+ forms: ["i", "í"]
8226
+ },
8227
+ {
8228
+ ending: "o",
8229
+ gate: /o$/u,
8230
+ forms: [
8231
+ "a",
8232
+ "ovi",
8233
+ "em",
8234
+ "om"
8235
+ ]
8236
+ }
8066
8237
  ];
8067
8238
  /**
8068
- * Strip common Czech/Slovak case suffixes from a token.
8069
- * Returns candidate base forms if stripping produces a
8070
- * plausible name (capitalised, length >= 3).
8071
- *
8072
- * For the "-ou" instrumental feminine suffix, also yields
8073
- * base + "a" (e.g., "Editou" → "Edit" and "Edita")
8074
- * because Czech feminine names decline -a → -ou.
8239
+ * Generate declined Czech/Slovak variants of a
8240
+ * nominative name. Only variants licensed by the
8241
+ * ending-shape rules are produced, which bounds the
8242
+ * deny-list AC pattern growth. Returns an empty array
8243
+ * for names too short to decline safely.
8244
+ */
8245
+ const expandNameDeclensions = (name) => {
8246
+ if (name.length < 3) return [];
8247
+ const lower = name.toLowerCase();
8248
+ const variants = [];
8249
+ for (const rule of DECLENSION_RULES) {
8250
+ if (!rule.gate.test(lower)) continue;
8251
+ const stem = name.slice(0, name.length - rule.ending.length);
8252
+ if (stem.length < 2) continue;
8253
+ for (const form of rule.forms) variants.push(stem + form);
8254
+ }
8255
+ return variants;
8256
+ };
8257
+ /**
8258
+ * Strip Czech/Slovak case endings from a token using
8259
+ * the inverse of DECLENSION_RULES. Returns candidate
8260
+ * nominative forms; each candidate is validated against
8261
+ * the rule's shape gate, so only bases the paradigm
8262
+ * could actually have declined are proposed. Callers
8263
+ * check candidates against the name corpus.
8075
8264
  */
8076
8265
  const stripInflection = (token) => {
8077
8266
  const candidates = [];
8078
- for (const suffix of INFLECTION_SUFFIXES) if (token.length > suffix.length + 2 && token.endsWith(suffix)) {
8079
- const base = token.slice(0, -suffix.length);
8080
- if (/^\p{Lu}/u.test(base)) {
8081
- candidates.push(base);
8082
- if (suffix === "ou" || suffix === "é" || suffix === "u") candidates.push(`${base}a`);
8083
- }
8267
+ for (const rule of DECLENSION_RULES) for (const form of rule.forms) {
8268
+ if (token.length <= form.length + 2 || !token.endsWith(form)) continue;
8269
+ const base = token.slice(0, token.length - form.length) + rule.ending;
8270
+ if (!/^\p{Lu}/u.test(base)) continue;
8271
+ if (!rule.gate.test(base.toLowerCase())) continue;
8272
+ candidates.push(base);
8084
8273
  }
8085
8274
  return candidates;
8086
8275
  };
@@ -8340,7 +8529,7 @@ const detectNameCorpus = (fullText, ctx = defaultContext) => {
8340
8529
  const SLASH_S_RE = /\/s\/[ \t]*/g;
8341
8530
  const LABELLED_NAME_RE = /\b(?:By|Name)[ \t]*:[ \t]*(?:\/s\/[ \t]+)?([^\n]*?)(?=\s{3,}|[\t]|\n|$)/gi;
8342
8531
  const WITNESS_ANCHOR_RE = /\bIN WITNESS WHEREOF\b[^\n]*\n/gi;
8343
- const NAME_PARTICLE = "(?:de|del|della|der|den|di|du|el|la|le|van|von|y|zu|af|ben|bin|al|d'|d’)";
8532
+ const NAME_PARTICLE = "(?:de|del|della|der|den|di|du|da|das|do|dos|el|la|le|van|von|y|zu|af|ben|bin|al|d'|d’)";
8344
8533
  const CAP_TOKEN = "\\p{Lu}[\\p{L}\\p{M}.'\\-]{0,30}";
8345
8534
  const NAME_SHAPE_RE = new RegExp(`^${CAP_TOKEN}(?:[ \\t]+(?:${NAME_PARTICLE}|${CAP_TOKEN})){1,4}$`, "u");
8346
8535
  const MAX_NAME_LEN = 60;
@@ -8592,6 +8781,35 @@ const HONORIFIC_BOUNDARY = new Set([
8592
8781
  "Señora"
8593
8782
  ]);
8594
8783
  /**
8784
+ * Honorifics that are abbreviations: a dot after them is an
8785
+ * abbreviation dot (same sentence), so the detector keeps an
8786
+ * optional `.` between the title and the name ("Mr. Smith").
8787
+ * Every other honorific is a full word — a trailing dot ends a
8788
+ * sentence, so the detector must NOT consume it (otherwise a span
8789
+ * like "President. The Employee" crosses the sentence boundary).
8790
+ *
8791
+ * Maintained explicitly, NOT derived from "ends in a dot": "Mr" is
8792
+ * an abbreviation written without a dot, and "Lord" is a full word.
8793
+ */
8794
+ const HONORIFIC_ABBREVIATION = new Set([
8795
+ "Avv.",
8796
+ "Dott.",
8797
+ "M.",
8798
+ "Me",
8799
+ "Messrs",
8800
+ "Mlle",
8801
+ "Mme",
8802
+ "Mr",
8803
+ "Mrs",
8804
+ "Ms",
8805
+ "Pr",
8806
+ "Pr.",
8807
+ "Sig.",
8808
+ "Sig.ra",
8809
+ "Sr.",
8810
+ "Sra."
8811
+ ]);
8812
+ /**
8595
8813
  * Post-nominal degrees (comma or space separated after name).
8596
8814
  * Plain text; the detector auto-escapes for regex.
8597
8815
  */
@@ -9118,11 +9336,14 @@ const POST_NOMINAL = POST_NOMINALS.toSorted((a, b) => b.length - a.length).map(e
9118
9336
  const NAME_WORD = `\\p{Lu}\\p{Ll}+`;
9119
9337
  const PARTICLE = "(?:van der|van den|de la|della|von|van|dos|ibn|ben|bin|del|zum|zur|ten|ter|da|de|di|al|el|le|la|zu|af|av)";
9120
9338
  const SP = "[^\\S\\n]";
9121
- /** Honorific alternation built from titles.ts config. */
9122
- const HONORIFIC_ALT = [...HONORIFICS].toSorted((a, b) => b.length - a.length).map((h) => {
9339
+ /** Honorific alternation built from titles.ts config. Sorted
9340
+ * longest-first so e.g. "Sig.ra" wins over "Sig.". */
9341
+ const buildHonorificAlt = (entries) => [...entries].toSorted((a, b) => b.length - a.length).map((h) => {
9123
9342
  const escaped = escapeRegex$2(h);
9124
9343
  return HONORIFIC_BOUNDARY.has(h) ? `\\b${escaped}` : escaped;
9125
9344
  }).join("|");
9345
+ const HONORIFIC_ABBREV_ALT = buildHonorificAlt(HONORIFICS.filter((h) => HONORIFIC_ABBREVIATION.has(h)));
9346
+ const HONORIFIC_FULLWORD_ALT = buildHonorificAlt(HONORIFICS.filter((h) => !HONORIFIC_ABBREVIATION.has(h)));
9126
9347
  const AMOUNT_WORDS = amount_words_default;
9127
9348
  const toEntry = (validator, label, score) => {
9128
9349
  const pattern = toRegex(validator).source;
@@ -9214,7 +9435,7 @@ const TITLED_PERSON = {
9214
9435
  score: .95
9215
9436
  };
9216
9437
  const HONORIFIC_PERSON = {
9217
- pattern: `(?:${HONORIFIC_ALT})\\.?${SP}+${NAME_WORD}(?:(?:${SP}|-){1,2}(?:${PARTICLE}${SP}+)?${NAME_WORD}){0,3}(?:${SP}+(?:QC|KC|SC|LJ|AG))?`,
9438
+ pattern: `(?:(?:${HONORIFIC_ABBREV_ALT})\\.?|(?:${HONORIFIC_FULLWORD_ALT}))${SP}+${NAME_WORD}(?:(?:${SP}|-){1,2}(?:${PARTICLE}${SP}+)?${NAME_WORD}){0,3}(?:${SP}+(?:QC|KC|SC|LJ|AG))?`,
9218
9439
  label: "person",
9219
9440
  score: .95
9220
9441
  };
@@ -10089,7 +10310,8 @@ const dropOverlapping = (candidates) => {
10089
10310
  //#region src/detectors/triggers.ts
10090
10311
  const VALID_ID_VALIDATORS = {
10091
10312
  "br.cpf": br.cpf,
10092
- "br.cnpj": br.cnpj
10313
+ "br.cnpj": br.cnpj,
10314
+ "us.rtn": us.rtn
10093
10315
  };
10094
10316
  const TRIGGER_SCORE = .95;
10095
10317
  const WHITESPACE_RE$1 = /\s+/;
@@ -10105,6 +10327,8 @@ const DECIMAL_COMMA_RE = new RegExp(`^,(?:\\d|${DASH}{1,2})`);
10105
10327
  * etc.), skip the comma and degree, then continue.
10106
10328
  */
10107
10329
  const POST_NOMINAL_RE = new RegExp(`^,\\s*(?:${POST_NOMINALS.toSorted((a, b) => b.length - a.length).map((d) => d.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\./g, "\\.\\s*")).join("|")})\\.?`, "i");
10330
+ const PERSON_NAME_TOKEN = "\\p{Lu}[\\p{L}\\p{M}.'’\\-]*";
10331
+ const PERSON_NAME_RUN_RE = new RegExp(`^${PERSON_NAME_TOKEN}(?:,?[ \\t]+(?:${`(?:${NAME_PARTICLE}[ \\t]+|d['’])`}){0,2}${PERSON_NAME_TOKEN})*`, "u");
10108
10332
  let cachedLegalFormCheckSource = null;
10109
10333
  let cachedLegalFormCheckForms = [];
10110
10334
  const LEGAL_FORM_ALNUM_RE = /[\p{L}\p{N}]/u;
@@ -10772,9 +10996,16 @@ const processTriggerMatches = (allMatches, sliceStart, sliceEnd, fullText, rules
10772
10996
  if (!applyValidations(value.text, rule.validations)) continue;
10773
10997
  if (rule.label === "phone number" && !isPlausiblePhoneTriggerValue(value.text)) continue;
10774
10998
  const entityStart = rule.includeTrigger ? match.start : value.start;
10775
- const entityEnd = value.end;
10776
- const entityText = fullText.slice(entityStart, entityEnd);
10999
+ let entityEnd = value.end;
11000
+ let entityText = fullText.slice(entityStart, entityEnd);
10777
11001
  const effectiveLabel = rule.label === "person" && hasKnownLegalFormSuffix(entityText) ? "organization" : rule.label;
11002
+ if (effectiveLabel === "person") {
11003
+ const run = PERSON_NAME_RUN_RE.exec(value.text);
11004
+ if (run !== null && run[0].length < value.text.length) {
11005
+ entityEnd = value.start + run[0].length;
11006
+ entityText = fullText.slice(entityStart, entityEnd);
11007
+ }
11008
+ }
10778
11009
  results.push({
10779
11010
  start: entityStart,
10780
11011
  end: entityEnd,
@@ -11398,6 +11629,9 @@ const EMPTY_ALLOW_LIST = /* @__PURE__ */ new Set();
11398
11629
  const getAllowList = (ctx) => ctx.allowList ?? EMPTY_ALLOW_LIST;
11399
11630
  let commonWordsPromise = null;
11400
11631
  let commonWordsCache = null;
11632
+ const EMPTY_COMMON_WORDS = /* @__PURE__ */ new Set();
11633
+ /** Sync accessor — returns empty set before init. */
11634
+ const getCommonWords = () => commonWordsCache ?? EMPTY_COMMON_WORDS;
11401
11635
  const loadCommonWords = () => {
11402
11636
  if (commonWordsCache) return Promise.resolve(commonWordsCache);
11403
11637
  if (commonWordsPromise) return commonWordsPromise;
@@ -11845,8 +12079,21 @@ const appendNameCorpusEntries = (config, ctx, patternList, labelList, sourceList
11845
12079
  sourceList.push(source);
11846
12080
  }
11847
12081
  };
11848
- for (const name of getNameCorpusFirstNames(ctx)) addNameEntry(name, "first-name");
11849
- for (const name of getNameCorpusSurnames(ctx)) addNameEntry(name, "surname");
12082
+ const commonWords = getCommonWords();
12083
+ const addDeclinedVariants = (name, source) => {
12084
+ for (const variant of expandNameDeclensions(name)) {
12085
+ if (commonWords.has(variant.toLowerCase())) continue;
12086
+ addNameEntry(variant, source);
12087
+ }
12088
+ };
12089
+ for (const name of getNameCorpusFirstNames(ctx)) {
12090
+ addNameEntry(name, "first-name");
12091
+ addDeclinedVariants(name, "first-name");
12092
+ }
12093
+ for (const name of getNameCorpusSurnames(ctx)) {
12094
+ addNameEntry(name, "surname");
12095
+ addDeclinedVariants(name, "surname");
12096
+ }
11850
12097
  for (const title of getNameCorpusTitles(ctx)) {
11851
12098
  const norm = stripCuratedPatternSyntax(normalizeForSearch(title));
11852
12099
  if (norm.length === 0) continue;
@@ -12623,25 +12870,33 @@ const isCallerOwnedEntity$2 = (entity) => entity.sourceDetail === "custom-deny-l
12623
12870
  * to get the base name, and re-scan the full text for
12624
12871
  * bare mentions of that base name. Returns new entities
12625
12872
  * for occurrences not already covered.
12873
+ *
12874
+ * Propagated mentions are coref aliases: each carries
12875
+ * `corefSourceText` linking it to the full seed entity
12876
+ * text, so placeholder numbering assigns the bare
12877
+ * mention the same placeholder as its source ("Acme"
12878
+ * and "Acme Corp." both become [ORGANIZATION_1]).
12626
12879
  */
12627
12880
  const propagateOrgNames = (entities, fullText) => {
12628
- const seeds = [];
12629
- const seenBases = /* @__PURE__ */ new Set();
12881
+ const seedByBase = /* @__PURE__ */ new Map();
12630
12882
  for (const e of entities) {
12631
12883
  if (e.label !== "organization") continue;
12632
12884
  if (isCallerOwnedEntity$2(e)) continue;
12633
12885
  for (const suffix of LEGAL_SUFFIXES) if (e.text.endsWith(suffix)) {
12634
12886
  const base = e.text.slice(0, -suffix.length).replace(TRAILING_SEP, "").trim();
12635
- if (base.length >= 3 && !seenBases.has(base)) {
12636
- seenBases.add(base);
12637
- seeds.push({
12887
+ if (base.length >= 3) {
12888
+ const existing = seedByBase.get(base);
12889
+ if (existing === void 0) seedByBase.set(base, {
12638
12890
  baseName: base,
12639
- label: e.label
12891
+ label: e.label,
12892
+ sourceText: e.text
12640
12893
  });
12894
+ else if (existing.sourceText !== e.text) existing.sourceText = base;
12641
12895
  }
12642
12896
  break;
12643
12897
  }
12644
12898
  }
12899
+ const seeds = [...seedByBase.values()];
12645
12900
  if (seeds.length === 0) return [];
12646
12901
  const covered = entities.map((e) => [e.start, e.end]);
12647
12902
  const isOverlapping = (start, end) => covered.some(([cs, ce]) => start < ce && end > cs);
@@ -12675,7 +12930,8 @@ const propagateOrgNames = (entities, fullText) => {
12675
12930
  label,
12676
12931
  text: fullText.slice(spanStart, matchEnd),
12677
12932
  score: ORG_PROPAGATION_SCORE,
12678
- source: DETECTION_SOURCES.COREFERENCE
12933
+ source: DETECTION_SOURCES.COREFERENCE,
12934
+ corefSourceText: seed.sourceText
12679
12935
  });
12680
12936
  covered.push([spanStart, matchEnd]);
12681
12937
  }
@@ -14764,7 +15020,6 @@ const runPipeline = async (options) => {
14764
15020
  const merged = filterFalsePositives(postOrgEntities, ctx, fullText);
14765
15021
  if (merged.length < postOrgEntities.length) log("filter", `removed ${postOrgEntities.length - merged.length} FPs`);
14766
15022
  checkAbort(signal);
14767
- ctx.corefSourceMap.clear();
14768
15023
  if (config.enableCoreference) {
14769
15024
  if (!legalFormsEnabled) await warmLegalRoleHeads();
14770
15025
  const terms = await extractDefinedTerms(fullText, merged.filter((entity) => !isCallerOwnedEntity(entity)), ctx);
@@ -14830,12 +15085,11 @@ const normalizeEntityText = (label, text) => {
14830
15085
  * Placeholder format: [LABEL_N] where LABEL is uppercase
14831
15086
  * and N is a 1-based counter per label.
14832
15087
  *
14833
- * @param ctx Pipeline context. Must be the same instance
14834
- * passed to `runPipeline` (or `findCoreferenceSpans`)
14835
- * so coreference placeholder links are preserved.
14836
- * Defaults to `defaultContext` for single-tenant usage.
15088
+ * @param _ctx Unused. Kept for signature compatibility;
15089
+ * coref alias links now travel on the entities
15090
+ * themselves (`corefSourceText`).
14837
15091
  */
14838
- const buildPlaceholderMap = (entities, ctx = defaultContext) => {
15092
+ const buildPlaceholderMap = (entities, _ctx = defaultContext) => {
14839
15093
  const counters = /* @__PURE__ */ new Map();
14840
15094
  const textLabelToPlaceholder = /* @__PURE__ */ new Map();
14841
15095
  const normalizedToPlaceholder = /* @__PURE__ */ new Map();
@@ -14844,9 +15098,9 @@ const buildPlaceholderMap = (entities, ctx = defaultContext) => {
14844
15098
  const compositeKey = `${entity.label}\0${entity.text}`;
14845
15099
  if (textLabelToPlaceholder.has(compositeKey)) continue;
14846
15100
  const labelKey = entity.label.toUpperCase().replace(WHITESPACE_RE, "_");
14847
- const sourceText = ctx.corefSourceMap.get(corefKey(entity));
14848
- if (sourceText !== void 0) {
14849
- const sourceNormalizedKey = `${labelKey}\0${normalizeEntityText(entity.label, sourceText)}`;
15101
+ const sourceText = entity.source === "coreference" ? entity.corefSourceText : void 0;
15102
+ const sourceNormalizedKey = sourceText === void 0 ? void 0 : `${labelKey}\0${normalizeEntityText(entity.label, sourceText)}`;
15103
+ if (sourceNormalizedKey !== void 0) {
14850
15104
  const sourceExisting = normalizedToPlaceholder.get(sourceNormalizedKey);
14851
15105
  if (sourceExisting) {
14852
15106
  textLabelToPlaceholder.set(compositeKey, sourceExisting);
@@ -14857,6 +15111,7 @@ const buildPlaceholderMap = (entities, ctx = defaultContext) => {
14857
15111
  const existing = normalizedToPlaceholder.get(normalizedKey);
14858
15112
  if (existing) {
14859
15113
  textLabelToPlaceholder.set(compositeKey, existing);
15114
+ if (sourceNormalizedKey !== void 0) normalizedToPlaceholder.set(sourceNormalizedKey, existing);
14860
15115
  continue;
14861
15116
  }
14862
15117
  const count = (counters.get(labelKey) ?? 0) + 1;
@@ -14864,6 +15119,7 @@ const buildPlaceholderMap = (entities, ctx = defaultContext) => {
14864
15119
  const placeholder = `[${labelKey}_${count}]`;
14865
15120
  textLabelToPlaceholder.set(compositeKey, placeholder);
14866
15121
  normalizedToPlaceholder.set(normalizedKey, placeholder);
15122
+ if (sourceNormalizedKey !== void 0) normalizedToPlaceholder.set(sourceNormalizedKey, placeholder);
14867
15123
  }
14868
15124
  return textLabelToPlaceholder;
14869
15125
  };
@@ -14906,7 +15162,7 @@ const redactText = (fullText, entities, config = DEFAULT_OPERATOR_CONFIG, ctx =
14906
15162
  const replacement = operator.apply(entity.text, entity.label, placeholder, config.redactString);
14907
15163
  parts.push(replacement);
14908
15164
  operatorMap.set(placeholder, opType);
14909
- if (operator.reversibility === "reversible" && !redactionMap.has(placeholder)) redactionMap.set(placeholder, entity.text);
15165
+ if (operator.reversibility === "reversible" && !redactionMap.has(placeholder)) redactionMap.set(placeholder, entity.source === "coreference" ? entity.corefSourceText : entity.text);
14910
15166
  cursor = entity.end;
14911
15167
  }
14912
15168
  if (cursor < fullText.length) parts.push(fullText.slice(cursor));