@stll/anonymize-wasm 1.4.11 → 1.5.0

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
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_ENTITY_LABELS, DETECTION_SOURCES, DETECTOR_PRIORITY, OPERATOR_TYPES } from "./constants.mjs";
2
2
  import { TextSearch } from "@stll/text-search-wasm";
3
- import { at, au, be, bg, br, ch, cn, cy, cz, de, dk, ee, es, fi, fr, gb, gr, hr, hu, ie, it, lt, lu, lv, mt, nl, no, pl, pt, ro, se, si, sk, us } from "@stll/stdnum";
3
+ import { at, au, be, bg, br, ch, cn, crypto, cy, cz, de, dk, ee, es, fi, fr, gb, gr, hr, hu, ie, it, lt, lu, lv, mt, nl, no, pl, pt, ro, se, si, sk, us } from "@stll/stdnum";
4
4
  import { toRegex } from "@stll/stdnum/patterns";
5
5
  //#region \0rolldown/runtime.js
6
6
  var __defProp = Object.defineProperty;
@@ -7959,6 +7959,15 @@ const processCountryMatches = (allMatches, sliceStart, sliceEnd, fullText, data)
7959
7959
  return results;
7960
7960
  };
7961
7961
  //#endregion
7962
+ //#region src/util/entity-source.ts
7963
+ /**
7964
+ * True when the entity came from a caller-supplied detector — a
7965
+ * custom deny-list entry or a custom regex. These spans carry
7966
+ * caller-locked boundaries: the pipeline does not sanitise, trim,
7967
+ * or boundary-adjust them, since the caller chose the exact text.
7968
+ */
7969
+ const isCallerOwnedEntity = (entity) => entity.sourceDetail === "custom-deny-list" || entity.sourceDetail === "custom-regex";
7970
+ //#endregion
7962
7971
  //#region src/config/titles.ts
7963
7972
  /**
7964
7973
  * Academic and professional title prefixes.
@@ -8405,6 +8414,7 @@ const initNameCorpus = (ctx = defaultContext, dictionaries, languages) => {
8405
8414
  titleTokens: Object.freeze(new Set(dedupTitles)),
8406
8415
  titleAbbreviations: Object.freeze(titleAbbrSet),
8407
8416
  excludedWords: Object.freeze(new Set(exclusions)),
8417
+ commonWords: Object.freeze(commonWords),
8408
8418
  nonWesternNames: Object.freeze(new Set(dedupNonWestern)),
8409
8419
  excludedAllCaps: Object.freeze(new Set(dedupExcludedAllCaps)),
8410
8420
  firstNamesList: Object.freeze(dedupFirst),
@@ -9119,13 +9129,14 @@ const detectNameCorpus = (fullText, ctx = defaultContext, options = {}) => {
9119
9129
  const corpusCount = chain.filter((t) => isCorpusMatch(t.type)).length;
9120
9130
  const capitalizedCount = chain.filter((t) => t.type === TOKEN_TYPE.CAPITALIZED).length;
9121
9131
  const nonWesternCount = chain.filter((t) => t.nonWestern).length;
9132
+ const chainAllCommonWords = chain.every((t) => corpus.commonWords.has(t.text.toLowerCase()));
9122
9133
  let score;
9123
9134
  if (hasNonWestern) {
9124
9135
  if (hasTitle && (nonWesternCount > 0 || capitalizedCount > 0)) score = .95;
9125
9136
  else if (hasJaSuffix && (capitalizedCount > 0 || nonWesternCount > 0)) score = .9;
9126
9137
  else if (hasArabicConnector && nonWesternCount > 0) score = .9;
9127
9138
  else if (nonWesternCount >= 2) score = .9;
9128
- else if (nonWesternCount > 0 && (capitalizedCount > 0 || hasAbbreviation)) score = .9;
9139
+ else if (nonWesternCount > 0 && (capitalizedCount > 0 || hasAbbreviation) && !chainAllCommonWords) score = .9;
9129
9140
  else if (nonWesternCount === 1 && chain.length === 1 && !isSentenceStart(fullText, token.start)) score = .5;
9130
9141
  else continue;
9131
9142
  if (supplementalMode && score < .9) continue;
@@ -9754,6 +9765,7 @@ var amount_words_default = {
9754
9765
  //#region src/detectors/regex.ts
9755
9766
  const MIN_PHONE_LENGTH = 7;
9756
9767
  const MIN_MONTH_NAME_LENGTH = 3;
9768
+ const US_STATE_CODE = "(?:(?i:A[KLZR]|C[AOT]|D[CE]|FL|GA|HI|I[ADL]|K[SY]|LA|M[ADEHINOPST]|N[CDEHJMVY]|O[HK]|PA|RI|S[CD]|T[NX]|UT|V[AIT]|W[AIVY])|I[Nn]|iN|O[Rr]|oR)";
9757
9769
  const escapeTitle = (title) => title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s*");
9758
9770
  /** Escape for use inside a regex alternation. */
9759
9771
  const escapeRegex$2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -9991,6 +10003,58 @@ const ES_CIF = {
9991
10003
  score: .95,
9992
10004
  validator: es.cif
9993
10005
  };
10006
+ const NHS_NUMBER_CONTEXT = {
10007
+ pattern: "\\b(?:(?i:NHS)(?:[^\\S\\n]+(?:(?i:number)|(?i:no\\.?)|#))?|(?i:National)[^\\S\\n]+(?i:Health)[^\\S\\n]+(?i:Service)[^\\S\\n]+(?:(?i:number)|(?i:no\\.?)|#))[^\\S\\n]{0,4}:?[^\\S\\n]{0,4}\\d{3}[^\\S\\n]?\\d{3}[^\\S\\n]?\\d{4}\\b",
10008
+ label: "national identification number",
10009
+ score: .95,
10010
+ validator: gb.nhs,
10011
+ validatorInput: (text) => text.replace(/\D/g, "")
10012
+ };
10013
+ const PASSPORT_CONTEXT = {
10014
+ pattern: "\\b(?:(?:(?i:U\\.?S\\.?)|(?i:USA)|(?i:United)[^\\S\\n]+(?i:States)|(?i:UK)|(?i:U\\.?K\\.?)|(?i:GBR)|(?i:British)|(?i:EU)|(?i:European)|(?i:France)|(?i:French))[^\\S\\n]{1,4})?(?i:passports?)(?:[^\\S\\n]+(?:(?i:number)|(?i:no\\.?)|#))?[^\\S\\n]{0,4}:?[^\\S\\n]{0,4}(?:[A-Za-z]{1,2}\\d{6,8}|\\d{2}[A-Za-z]{2}\\d{5}|\\d{7,9})\\b",
10015
+ label: "passport number",
10016
+ score: .96
10017
+ };
10018
+ const FR_CNI_CONTEXT = {
10019
+ pattern: "\\b(?:(?i:CNI)|(?i:carte)[^\\S\\n]+(?i:nationale)[^\\S\\n]+(?i:d)[’'](?i:identit[ée])|(?i:French)[^\\S\\n]+(?i:national)[^\\S\\n]+(?i:identity)[^\\S\\n]+(?i:card))(?:[^\\S\\n]+(?:(?i:number)|(?i:no\\.?)|(?i:n[°ºo]\\.?))[^\\S\\n]{0,4}:?[^\\S\\n]{0,4}[A-Za-z0-9]{9,12}|[^\\S\\n]{0,4}:?[^\\S\\n]{0,4}(?=[A-Za-z0-9]{0,11}\\d)[A-Za-z0-9]{9,12})\\b",
10020
+ label: "identity card number",
10021
+ score: .96
10022
+ };
10023
+ const CY_TIC_CONTEXT = {
10024
+ pattern: "\\b(?:(?:(?i:Cyprus)|(?i:Cypriot))[^\\S\\n]{1,4})?(?:(?i:TIC)|(?i:tax)[^\\S\\n]+(?i:identification)[^\\S\\n]+(?i:code))(?:[^\\S\\n]+(?:(?i:number)|(?i:no\\.?)|#))?[^\\S\\n]{0,4}:?[^\\S\\n]{0,4}\\d{8}[A-Za-z]\\b",
10025
+ label: "tax identification number",
10026
+ score: .96
10027
+ };
10028
+ const CY_ID_CARD_CONTEXT = {
10029
+ pattern: "\\b(?:(?i:Cyprus)|(?i:Cypriot))[^\\S\\n]+(?:(?i:identity)[^\\S\\n]+(?i:card)|(?i:ID)[^\\S\\n]+(?i:card))(?:[^\\S\\n]+(?:(?i:number)|(?i:no\\.?)|#))?[^\\S\\n]{0,4}:?[^\\S\\n]{0,4}\\d{6,8}\\b",
10030
+ label: "identity card number",
10031
+ score: .96
10032
+ };
10033
+ const UK_DRIVING_LICENCE_CONTEXT = {
10034
+ pattern: "\\b(?:(?:(?i:UK)|(?i:U\\.?K\\.?)|(?i:British))[^\\S\\n]{1,4})?(?i:driving)[^\\S\\n]+(?i:licen[cs]e)(?:[^\\S\\n]+(?:(?i:number)|(?i:no\\.?)|#))?[^\\S\\n]{0,4}:?[^\\S\\n]{0,4}[A-Za-z9]{5}\\d{6}[A-Za-z0-9]{2}\\d[A-Za-z]{2}\\b",
10035
+ label: "identity card number",
10036
+ score: .96
10037
+ };
10038
+ const US_DRIVER_LICENSE_CONTEXT = {
10039
+ pattern: `\\b(?:(?:(?i:U\\.?S\\.?)|(?i:USA)|(?i:United)[^\\S\\n]+(?i:States)|${US_STATE_CODE})[^\\S\\n]{1,4})?(?:(?i:driver['’]?s?)|(?i:driving))[^\\S\\n]+(?i:licen[cs]e)(?:[^\\S\\n]+(?:(?i:number)|(?i:no\\.?)|#)[^\\S\\n]{0,4}:?[^\\S\\n]{0,4}[A-Za-z0-9]{5,15}|[^\\S\\n]{0,4}:?[^\\S\\n]{0,4}(?=[A-Za-z0-9]{0,14}\\d)[A-Za-z0-9]{5,15})\\b`,
10040
+ label: "identity card number",
10041
+ score: .8
10042
+ };
10043
+ const MEDICAL_LICENSE_CONTEXT = {
10044
+ pattern: "\\b(?:(?:(?i:GMC)|(?i:NMC))(?:[^\\S\\n]+(?:(?i:licen[cs]e)|(?i:registration)|(?i:reg\\.?)|(?i:pin)|(?i:number)|(?i:no\\.?)))*|(?:(?i:medical)|(?i:physician)|(?i:doctor)|(?i:surgeon)|(?i:nursing)|(?i:nurse))(?:[^\\S\\n]+(?:(?i:licen[cs]e)|(?i:registration)|(?i:reg\\.?)|(?i:pin)|(?i:number)|(?i:no\\.?)))+)[^\\S\\n]{0,4}:?[^\\S\\n]{0,4}(?:[A-Za-z]{0,3}\\d{5,8}|\\d{2}[A-Za-z]\\d{4}[A-Za-z])\\b",
10045
+ label: "registration number",
10046
+ score: .85
10047
+ };
10048
+ const CRYPTO_WALLET_CANDIDATE = crypto.wallet.candidatePattern ?? "(?!)";
10049
+ const CRYPTO_WALLET_CANDIDATE_REGEX = new RegExp(CRYPTO_WALLET_CANDIDATE);
10050
+ const getCryptoWalletCandidate = (text) => CRYPTO_WALLET_CANDIDATE_REGEX.exec(text)?.[0] ?? text;
10051
+ const CRYPTO_WALLET_ADDRESS = {
10052
+ pattern: "\\b(?:0x[0-9A-Fa-f]{40}|bc1[ac-hj-np-z02-9]{11,71}|BC1[AC-HJ-NP-Z02-9]{11,71})\\b|\\b(?:(?i:BTC|Bitcoin|crypto|wallet|address)[^\\S\\n]{0,4}:?[^\\S\\n]{1,8}){1,4}[13][a-km-zA-HJ-NP-Z1-9]{25,34}\\b",
10053
+ label: "crypto",
10054
+ score: .85,
10055
+ validator: crypto.wallet,
10056
+ validatorInput: getCryptoWalletCandidate
10057
+ };
9994
10058
  const AU_ABN_FORMATTED = {
9995
10059
  pattern: `\\b\\d{2}[^\\S\\n]\\d{3}[^\\S\\n]\\d{3}[^\\S\\n]\\d{3}\\b`,
9996
10060
  label: "tax identification number",
@@ -10137,6 +10201,15 @@ const ALL_REGEX_DEFS = [
10137
10201
  ES_DNI,
10138
10202
  ES_NIE,
10139
10203
  ES_CIF,
10204
+ NHS_NUMBER_CONTEXT,
10205
+ PASSPORT_CONTEXT,
10206
+ FR_CNI_CONTEXT,
10207
+ CY_TIC_CONTEXT,
10208
+ CY_ID_CARD_CONTEXT,
10209
+ UK_DRIVING_LICENCE_CONTEXT,
10210
+ US_DRIVER_LICENSE_CONTEXT,
10211
+ MEDICAL_LICENSE_CONTEXT,
10212
+ CRYPTO_WALLET_ADDRESS,
10140
10213
  AU_ABN_FORMATTED,
10141
10214
  NO_ORGNR_FORMATTED,
10142
10215
  NO_MVA_FORMATTED,
@@ -10168,6 +10241,7 @@ const REGEX_META = ALL_REGEX_DEFS.map((d) => {
10168
10241
  score: d.score
10169
10242
  };
10170
10243
  if (d.validator) meta.validator = d.validator;
10244
+ if (d.validatorInput) meta.validatorInput = d.validatorInput;
10171
10245
  return meta;
10172
10246
  });
10173
10247
  /**
@@ -10439,7 +10513,8 @@ const processRegexMatches = (allMatches, sliceStart, sliceEnd, meta_) => {
10439
10513
  if (!meta) continue;
10440
10514
  if (meta.sourceDetail !== "custom-regex" && meta.label === "phone number" && match.text.length < MIN_PHONE_LENGTH) continue;
10441
10515
  if (meta.validator) {
10442
- const compacted = meta.validator.compact(match.text);
10516
+ const validatorText = meta.validatorInput ? meta.validatorInput(match.text) : match.text;
10517
+ const compacted = meta.validator.compact(validatorText);
10443
10518
  if (!meta.validator.validate(compacted).valid) continue;
10444
10519
  }
10445
10520
  const entity = {
@@ -11895,7 +11970,40 @@ const trimTrailingAddressProse = (text) => {
11895
11970
  }
11896
11971
  return text;
11897
11972
  };
11898
- const normalizeEntity = (entity) => {
11973
+ const collapseEntityWhitespace = (text) => text.replace(/\s*\n\s*/g, " ").replace(/\s{2,}/g, " ");
11974
+ const normalizeEntityFromRaw = (entity, fullText) => {
11975
+ let start = entity.start;
11976
+ let end = entity.end;
11977
+ let text = fullText.slice(start, end);
11978
+ const trimLeading = (re) => {
11979
+ const match = re.exec(text);
11980
+ if (!match) return;
11981
+ start += match[0].length;
11982
+ text = text.slice(match[0].length);
11983
+ };
11984
+ trimLeading(LEADING_ARTIFACT_RE);
11985
+ trimLeading(/^\s+/u);
11986
+ if (entity.label === "address") {
11987
+ trimLeading(ADDRESS_ROLE_PREFIX_RE);
11988
+ const beforeTrim = text;
11989
+ text = trimTrailingAddressProse(text);
11990
+ end -= beforeTrim.length - text.length;
11991
+ }
11992
+ const trailingMatch = /[,\s]+$/u.exec(text);
11993
+ if (trailingMatch) {
11994
+ end -= trailingMatch[0].length;
11995
+ text = text.slice(0, text.length - trailingMatch[0].length);
11996
+ }
11997
+ if (text.length === 0) return null;
11998
+ return {
11999
+ ...entity,
12000
+ start,
12001
+ end,
12002
+ text: collapseEntityWhitespace(text)
12003
+ };
12004
+ };
12005
+ const normalizeEntity = (entity, fullText) => {
12006
+ if (fullText !== void 0) return normalizeEntityFromRaw(entity, fullText);
11899
12007
  let start = entity.start;
11900
12008
  let text = entity.text;
11901
12009
  const trimLeading = (re) => {
@@ -11913,10 +12021,13 @@ const normalizeEntity = (entity) => {
11913
12021
  const trailingMatch = /[,\s]+$/u.exec(text);
11914
12022
  if (trailingMatch) text = text.slice(0, text.length - trailingMatch[0].length);
11915
12023
  if (text.length === 0) return null;
12024
+ const removedFromFront = start - entity.start;
12025
+ const removedFromBack = entity.text.length - removedFromFront - text.length;
12026
+ const end = entity.end - removedFromBack;
11916
12027
  return {
11917
12028
  ...entity,
11918
12029
  start,
11919
- end: start + text.length,
12030
+ end,
11920
12031
  text
11921
12032
  };
11922
12033
  };
@@ -12021,7 +12132,6 @@ const initAddressComponents = () => {
12021
12132
  * Czech house number form.
12022
12133
  */
12023
12134
  const hasAddressComponent = (text) => _streetTypesRe.test(text) || ADDRESS_COMPONENT_EXTRA_RE.test(text);
12024
- const isCallerOwnedEntity$3 = (entity) => entity.sourceDetail === "custom-deny-list" || entity.sourceDetail === "custom-regex";
12025
12135
  /**
12026
12136
  * Filter out entities that are likely false positives:
12027
12137
  * template placeholders, clause/section numbers,
@@ -12034,14 +12144,14 @@ const filterFalsePositives = (entities, ctx = defaultContext, fullText) => {
12034
12144
  const filtered = [];
12035
12145
  const roles = getGenericRoles(ctx);
12036
12146
  for (const entity of entities) {
12037
- if (isCallerOwnedEntity$3(entity)) {
12147
+ if (isCallerOwnedEntity(entity)) {
12038
12148
  filtered.push(entity);
12039
12149
  continue;
12040
12150
  }
12041
- const normalized = normalizeEntity(entity);
12151
+ const normalized = normalizeEntity(entity, fullText);
12042
12152
  if (!normalized) continue;
12043
12153
  const trimmed = normalized.text;
12044
- if (isCallerOwnedEntity$3(normalized)) {
12154
+ if (isCallerOwnedEntity(normalized)) {
12045
12155
  filtered.push(normalized);
12046
12156
  continue;
12047
12157
  }
@@ -12120,6 +12230,32 @@ const loadCommonWords = () => {
12120
12230
  })();
12121
12231
  return commonWordsPromise;
12122
12232
  };
12233
+ let monthNamesPromise = null;
12234
+ let monthNamesCache = null;
12235
+ /**
12236
+ * English month names, lowercased. City gazetteers carry a
12237
+ * handful of entries that collide with month words (e.g. a
12238
+ * place named "August"); in prose these surface as address
12239
+ * false positives where the token is plainly a date, so they
12240
+ * are dropped from address patterns at build time.
12241
+ */
12242
+ const loadMonthNames = () => {
12243
+ if (monthNamesCache) return Promise.resolve(monthNamesCache);
12244
+ if (monthNamesPromise) return monthNamesPromise;
12245
+ monthNamesPromise = (async () => {
12246
+ try {
12247
+ const mod = await import("./date-months.mjs");
12248
+ const set = new Set((mod.default?.en ?? []).map((month) => month.toLowerCase()));
12249
+ monthNamesCache = set;
12250
+ return set;
12251
+ } catch {
12252
+ const empty = /* @__PURE__ */ new Set();
12253
+ monthNamesCache = empty;
12254
+ return empty;
12255
+ }
12256
+ })();
12257
+ return monthNamesPromise;
12258
+ };
12123
12259
  /**
12124
12260
  * Curated dictionary entries that are pure dotted
12125
12261
  * single-letter acronyms (e.g. `S.C.`, `D.N.J.`, `C.E.C.`)
@@ -12433,10 +12569,12 @@ const buildDenyList = async (config, ctx = defaultContext) => {
12433
12569
  loadPersonStopwords(ctx),
12434
12570
  loadAddressStopwords(ctx),
12435
12571
  loadCommonWords(),
12572
+ loadMonthNames(),
12436
12573
  loadStreetTypeRe(),
12437
12574
  loadGenericRoles(ctx)
12438
12575
  ]);
12439
12576
  const commonWords = await loadCommonWords();
12577
+ const monthNames = await loadMonthNames();
12440
12578
  const dictionaries = config.dictionaries;
12441
12579
  const hasDenyList = dictionaries?.denyList && dictionaries?.denyListMeta;
12442
12580
  const hasCustomDenyList = config.customDenyList !== void 0 && config.customDenyList.length > 0;
@@ -12455,9 +12593,11 @@ const buildDenyList = async (config, ctx = defaultContext) => {
12455
12593
  const normalized = source === "custom-deny-list" ? normalizeForSearch(entry) : stripCuratedPatternSyntax(normalizeForSearch(entry));
12456
12594
  if (normalized.length === 0) return;
12457
12595
  const lower = normalized.toLowerCase();
12458
- if (source !== "custom-deny-list" && label !== "address") {
12459
- if (SINGLE_WORD_RE.test(normalized) && commonWords.has(lower)) return;
12460
- if (isShortCuratedNoiseAcronym(normalized)) return;
12596
+ if (source !== "custom-deny-list") {
12597
+ if (label !== "address") {
12598
+ if (SINGLE_WORD_RE.test(normalized) && commonWords.has(lower)) return;
12599
+ if (isShortCuratedNoiseAcronym(normalized)) return;
12600
+ } else if (monthNames.has(lower)) return;
12461
12601
  }
12462
12602
  const existing = patternIndex.get(lower);
12463
12603
  if (existing !== void 0) {
@@ -13336,7 +13476,6 @@ const TRAILING_SEP = /[,\s]+$/;
13336
13476
  const WORD_CHAR_RE = /[\p{L}\p{N}]/u;
13337
13477
  const ORG_PROPAGATION_SCORE = .9;
13338
13478
  const ORG_DETERMINER_RE = /(?<![\p{L}\p{N}])(společnost(?:i|í|em|u)?|spolecnost(?:i|em|u)?|the\s+(?:company|corporation|firm)|die\s+(?:gesellschaft|firma)|la\s+(?:société|empresa|sociedad)|el\s+(?:empresa|sociedad))\s+$/iu;
13339
- const isCallerOwnedEntity$2 = (entity) => entity.sourceDetail === "custom-deny-list" || entity.sourceDetail === "custom-regex";
13340
13479
  /**
13341
13480
  * After the main detection pass, collect organization
13342
13481
  * entities with a legal form suffix, strip the suffix
@@ -13354,7 +13493,7 @@ const propagateOrgNames = (entities, fullText) => {
13354
13493
  const seedByBase = /* @__PURE__ */ new Map();
13355
13494
  for (const e of entities) {
13356
13495
  if (e.label !== "organization") continue;
13357
- if (isCallerOwnedEntity$2(e)) continue;
13496
+ if (isCallerOwnedEntity(e)) continue;
13358
13497
  for (const suffix of LEGAL_SUFFIXES) if (e.text.endsWith(suffix)) {
13359
13498
  const base = e.text.slice(0, -suffix.length).replace(TRAILING_SEP, "").trim();
13360
13499
  if (base.length >= 3) {
@@ -13724,7 +13863,6 @@ const NEAR_MISS_BAND = .15;
13724
13863
  const BOOST_PER_NEIGHBOUR = .05;
13725
13864
  const CONTEXT_WINDOW_CHARS = 150;
13726
13865
  const HIGH_CONFIDENCE_FLOOR = .9;
13727
- const isCallerOwnedEntity$1 = (entity) => entity.sourceDetail === "custom-deny-list" || entity.sourceDetail === "custom-regex";
13728
13866
  /**
13729
13867
  * Boost confidence of near-miss NER entities that appear
13730
13868
  * near high-confidence detections (regex, trigger phrase).
@@ -13756,7 +13894,7 @@ const boostNearMissEntities = (entities, threshold) => {
13756
13894
  const boostedScore = entity.score + neighbourCount * BOOST_PER_NEIGHBOUR;
13757
13895
  if (boostedScore >= threshold) boosted.push({
13758
13896
  ...entity,
13759
- score: boostedScore
13897
+ score: Math.min(1, boostedScore)
13760
13898
  });
13761
13899
  }
13762
13900
  return boosted;
@@ -13835,7 +13973,7 @@ const getStreetAbbrevs = () => _streetAbbrevs ?? /* @__PURE__ */ new Set();
13835
13973
  */
13836
13974
  const detectStreetPatternsNearAddresses = (fullText, existingEntities) => {
13837
13975
  const results = [];
13838
- const addressEntities = existingEntities.filter((entity) => !(entity.label === "address" && isCallerOwnedEntity$1(entity))).filter((e) => e.label === "address");
13976
+ const addressEntities = existingEntities.filter((entity) => !(entity.label === "address" && isCallerOwnedEntity(entity))).filter((e) => e.label === "address");
13839
13977
  const headerEnd = Math.floor(fullText.length * HEADER_ZONE_FRACTION);
13840
13978
  const houseNumRe = /\b(?:\d{1,4}\/\d+[a-zA-Z]\b|\d{3,4}\/\d+\b|(?:1[3-9]|[2-9]\d)\/\d{3,}\b)/g;
13841
13979
  houseNumRe.lastIndex = 0;
@@ -13934,7 +14072,7 @@ const ORPHAN_STREET_RE = /^[^\S\n]*(\p{Lu}[\p{Ll}\p{Lu}]+(?:[^\S\n]+[\p{Lu}\p{Ll
13934
14072
  */
13935
14073
  const detectOrphanStreetLines = (fullText, existingEntities) => {
13936
14074
  const headerEnd = Math.floor(fullText.length * HEADER_ZONE_FRACTION);
13937
- const contextEntities = existingEntities.filter((entity) => !(entity.label === "address" && isCallerOwnedEntity$1(entity)));
14075
+ const contextEntities = existingEntities.filter((entity) => !(entity.label === "address" && isCallerOwnedEntity(entity)));
13938
14076
  const results = [];
13939
14077
  ORPHAN_STREET_RE.lastIndex = 0;
13940
14078
  for (let m = ORPHAN_STREET_RE.exec(fullText); m !== null; m = ORPHAN_STREET_RE.exec(fullText)) {
@@ -14292,7 +14430,7 @@ const applyHotwordRules = (entities, fullText) => {
14292
14430
  //#region src/filters/boundary-consistency.ts
14293
14431
  /** Max gap (in chars) between entities to merge. */
14294
14432
  const MAX_GAP = 3;
14295
- const hasLockedBoundary$1 = (entity) => entity.sourceDetail === "custom-deny-list" || entity.sourceDetail === "custom-regex";
14433
+ const hasLockedBoundary$1 = (entity) => isCallerOwnedEntity(entity);
14296
14434
  const hasDetectorLockedBoundary = (entity) => entity.label === "phone number" && entity.source === DETECTION_SOURCES.TRIGGER;
14297
14435
  /**
14298
14436
  * Characters allowed in the gap between two adjacent
@@ -14875,7 +15013,6 @@ const unmaskNerEntities = (nerEntities, maskResult, fullText) => {
14875
15013
  * so the containment rule trusts their length.
14876
15014
  */
14877
15015
  const LITERAL_SOURCES = new Set(["deny-list", "gazetteer"]);
14878
- const isCallerOwnedEntity = (entity) => entity.sourceDetail === "custom-deny-list" || entity.sourceDetail === "custom-regex";
14879
15016
  const hasLockedBoundary = (entity) => isCallerOwnedEntity(entity);
14880
15017
  const isCoveredByDenyListEntity = (entity, denyListEntity) => entity.label === denyListEntity.label && denyListEntity.start <= entity.start && denyListEntity.end >= entity.end;
14881
15018
  const filterNameCorpusCoveredByDenyList = (nameCorpusEntities, denyListEntities) => nameCorpusEntities.filter((entity) => !denyListEntities.some((denyListEntity) => isCoveredByDenyListEntity(entity, denyListEntity)));
@@ -15099,7 +15236,7 @@ const sanitizeEntities = (entities) => entities.flatMap((e) => {
15099
15236
  return [{
15100
15237
  ...e,
15101
15238
  start: e.start + lead,
15102
- end: e.start + lead + collapsed.length,
15239
+ end: e.start + lead + cleaned.length,
15103
15240
  text: collapsed
15104
15241
  }];
15105
15242
  });
@@ -15215,7 +15352,7 @@ const filterAllowedLabels = (entities, allowedLabels) => {
15215
15352
  return entities.filter((e) => allowedLabels.has(e.label));
15216
15353
  };
15217
15354
  const labelIsAllowed = (label, allowedLabels) => !allowedLabels || allowedLabels.has(label);
15218
- const NON_NER_LABELS = new Set(["misc"]);
15355
+ const NON_NER_LABELS = new Set(["crypto", "misc"]);
15219
15356
  const getRequestedNerLabels = (config, expandForHotwords = false) => {
15220
15357
  const labels = config.labels.length > 0 ? config.labels : DEFAULT_ENTITY_LABELS;
15221
15358
  return (expandForHotwords ? expandLabelsForHotwordRules(labels) : labels).filter((label) => !NON_NER_LABELS.has(label));
@@ -15545,7 +15682,23 @@ const resolveOperator = (config, label) => config.operators[label] ?? "replace";
15545
15682
  //#region src/redact.ts
15546
15683
  const WHITESPACE_RE = /\s+/g;
15547
15684
  const PHONE_NOISE_RE = /[()\s-]/g;
15685
+ const ETHEREUM_ADDRESS_RE = /0x[0-9A-Fa-f]{40}/;
15686
+ const BECH32_ADDRESS_RE = /\bbc1[ac-hj-np-z02-9]{11,71}\b/i;
15687
+ const BASE58_ADDRESS_RE = /\b[13][a-km-zA-HJ-NP-Z1-9]{25,34}\b/;
15688
+ const NHS_NUMBER_CUE_RE = /\b(?:NHS|National\s+Health\s+Service)\b/i;
15689
+ const PASSPORT_IDENTIFIER_RE = /\b(?:[A-Za-z]{1,2}\d{6,8}|\d{2}[A-Za-z]{2}\d{5}|\d{7,9})\b/;
15548
15690
  const ID_SEPARATOR_RE = /[\s\-/.]/g;
15691
+ const normalizeCryptoText = (text) => {
15692
+ const trimmed = text.trim();
15693
+ const ethereumAddress = ETHEREUM_ADDRESS_RE.exec(trimmed)?.[0];
15694
+ if (ethereumAddress) return ethereumAddress.toLowerCase();
15695
+ const bech32Address = BECH32_ADDRESS_RE.exec(trimmed)?.[0];
15696
+ if (bech32Address) return bech32Address.toLowerCase();
15697
+ return BASE58_ADDRESS_RE.exec(trimmed)?.[0] ?? trimmed;
15698
+ };
15699
+ const normalizePassportText = (text) => {
15700
+ return (PASSPORT_IDENTIFIER_RE.exec(text)?.[0] ?? text).replace(ID_SEPARATOR_RE, "").toUpperCase();
15701
+ };
15549
15702
  /**
15550
15703
  * Normalize entity text so that surface-form variations
15551
15704
  * of the same real-world value map to a single canonical
@@ -15555,7 +15708,10 @@ const normalizeEntityText = (label, text) => {
15555
15708
  const upper = label.toUpperCase().replace(WHITESPACE_RE, "_");
15556
15709
  if (upper === "EMAIL_ADDRESS" || upper === "EMAIL") return text.toLowerCase().trim();
15557
15710
  if (upper === "PHONE_NUMBER" || upper === "PHONE") return text.replace(PHONE_NOISE_RE, "");
15558
- if (upper === "IBAN" || upper === "BANK_ACCOUNT_NUMBER" || upper === "TAX_IDENTIFICATION_NUMBER" || upper === "REGISTRATION_NUMBER" || upper === "NATIONAL_IDENTIFICATION_NUMBER" || upper === "SOCIAL_SECURITY_NUMBER" || upper === "BIRTH_NUMBER" || upper === "IDENTITY_CARD_NUMBER" || upper === "PASSPORT_NUMBER" || upper === "CREDIT_CARD_NUMBER") return text.replace(ID_SEPARATOR_RE, "").toUpperCase();
15711
+ if (upper === "CRYPTO") return normalizeCryptoText(text);
15712
+ if (upper === "NATIONAL_IDENTIFICATION_NUMBER" && NHS_NUMBER_CUE_RE.test(text)) return text.replace(/\D/g, "");
15713
+ if (upper === "IBAN" || upper === "BANK_ACCOUNT_NUMBER" || upper === "TAX_IDENTIFICATION_NUMBER" || upper === "REGISTRATION_NUMBER" || upper === "NATIONAL_IDENTIFICATION_NUMBER" || upper === "SOCIAL_SECURITY_NUMBER" || upper === "BIRTH_NUMBER" || upper === "IDENTITY_CARD_NUMBER" || upper === "CREDIT_CARD_NUMBER") return text.replace(ID_SEPARATOR_RE, "").toUpperCase();
15714
+ if (upper === "PASSPORT_NUMBER") return normalizePassportText(text);
15559
15715
  if (upper === "PERSON" || upper === "ORGANIZATION" || upper === "ADDRESS" || upper === "LAND_PARCEL" || upper === "MISC") return text.replace(WHITESPACE_RE, " ").toLowerCase().trim();
15560
15716
  return text.trim();
15561
15717
  };
@@ -15688,7 +15844,7 @@ const hasOverlapping = (a, b, multiLabel) => {
15688
15844
  const b0 = b[0] ?? 0;
15689
15845
  const b1 = b[1] ?? 0;
15690
15846
  if (a0 === b0 && a1 === b1) return !multiLabel;
15691
- return !(a0 > b1 || b0 > a1);
15847
+ return a0 < b1 && b0 < a1;
15692
15848
  };
15693
15849
  /**
15694
15850
  * Greedy non-overlapping span selection.
@@ -15900,7 +16056,12 @@ const encodeInputs = (tokenizer, texts, promptLengths) => {
15900
16056
  allWordsMasks
15901
16057
  ];
15902
16058
  };
15903
- /** Build span index pairs and masks for the model. */
16059
+ /**
16060
+ * Build span index pairs and masks for the model.
16061
+ *
16062
+ * Exported for unit testing the span-mask invariant; not part
16063
+ * of the public package barrel.
16064
+ */
15904
16065
  const prepareSpans = (batchTokens, maxWidth) => {
15905
16066
  const spanIdxs = [];
15906
16067
  const spanMasks = [];
@@ -15911,7 +16072,7 @@ const prepareSpans = (batchTokens, maxWidth) => {
15911
16072
  for (let i = 0; i < len; i++) for (let j = 0; j < maxWidth; j++) {
15912
16073
  const endIdx = Math.min(i + j, len - 1);
15913
16074
  idx.push([i, endIdx]);
15914
- mask.push(endIdx < len);
16075
+ mask.push(i + j < len);
15915
16076
  }
15916
16077
  spanIdxs.push(idx);
15917
16078
  spanMasks.push(mask);
@@ -15977,13 +16138,20 @@ const MAX_CHUNK_CHARS = 1500;
15977
16138
  const OVERLAP_CHARS = 50;
15978
16139
  const MIN_CHUNK_LENGTH = 10;
15979
16140
  /**
15980
- * Split text into overlapping chunks for GLiNER's
15981
- * ~512 token context window. Character-based splitting
15982
- * (rough approximation of token limits).
16141
+ * Split text into overlapping chunks, each paired with its
16142
+ * exact start offset in the source text.
16143
+ *
16144
+ * Carrying the offset out of the splitter is the robust way to
16145
+ * map chunk-local entity offsets back to document offsets:
16146
+ * downstream code never has to re-locate a chunk by content
16147
+ * search (which mis-locates when boilerplate repeats; see
16148
+ * computeChunkOffsets).
15983
16149
  *
15984
- * Tries to break at sentence boundaries when possible.
16150
+ * Character-based splitting (rough token approximation for
16151
+ * GLiNER's ~512 token window); breaks at sentence boundaries
16152
+ * when possible.
15985
16153
  */
15986
- const chunkText = (text) => {
16154
+ const chunkTextWithOffsets = (text) => {
15987
16155
  const chunks = [];
15988
16156
  let offset = 0;
15989
16157
  while (offset < text.length) {
@@ -15993,14 +16161,34 @@ const chunkText = (text) => {
15993
16161
  if (lastPeriod > MAX_CHUNK_CHARS * .5) end = offset + lastPeriod + 2;
15994
16162
  }
15995
16163
  const chunk = text.slice(offset, end);
15996
- if (chunk.trim().length > MIN_CHUNK_LENGTH) chunks.push(chunk);
16164
+ if (chunk.trim().length > MIN_CHUNK_LENGTH) chunks.push({
16165
+ text: chunk,
16166
+ offset
16167
+ });
16168
+ if (end === text.length) break;
15997
16169
  offset = Math.max(offset + 1, end - OVERLAP_CHARS);
15998
16170
  }
15999
16171
  return chunks;
16000
16172
  };
16001
16173
  /**
16002
- * Compute the byte offset of each chunk within the
16003
- * original document text.
16174
+ * Split text into overlapping chunks for GLiNER's ~512 token
16175
+ * context window. Character-based splitting (rough token
16176
+ * approximation); breaks at sentence boundaries when possible.
16177
+ *
16178
+ * Prefer chunkTextWithOffsets when you also need each chunk's
16179
+ * document offset.
16180
+ */
16181
+ const chunkText = (text) => chunkTextWithOffsets(text).map((chunk) => chunk.text);
16182
+ /**
16183
+ * Compute the start offset of each chunk within the original
16184
+ * document text by content search.
16185
+ *
16186
+ * @deprecated Re-locates each chunk with `indexOf`, which can
16187
+ * match the wrong position when identical content repeats in
16188
+ * the document (common in boilerplate-heavy legal text) and
16189
+ * then desyncs every subsequent offset. Use
16190
+ * `chunkTextWithOffsets`, which carries exact offsets out of
16191
+ * the splitter.
16004
16192
  */
16005
16193
  const computeChunkOffsets = (fullText, chunks) => {
16006
16194
  const offsets = [];
@@ -16101,6 +16289,6 @@ const levenshtein = (rawA, rawB) => {
16101
16289
  //#region src/wasm.ts
16102
16290
  initTextSearch(TextSearch);
16103
16291
  //#endregion
16104
- export { CURRENCY_PATTERN_META, DATE_PATTERN_META, DEFAULT_ENTITY_LABELS, DEFAULT_OPERATOR_CONFIG, DETECTION_SOURCES, DETECTOR_PRIORITY, OPERATOR_REGISTRY, OPERATOR_TYPES, REGEX_META, REGEX_PATTERNS, REGIONS, ZONE_SCORE_ADJUSTMENTS, applyHotwordRules, applyZoneAdjustments, boostNearMissEntities, buildDenyList, buildGazetteerPatterns, buildLegalFormPatterns, buildPlaceholderMap, buildStreetTypePatterns, buildTriggerPatterns, buildUnifiedSearch, chunkText, classifyZones, computeChunkOffsets, corefKey, createPipelineContext, deanonymise, decodeSpans, decodeTokenSpans, detectNameCorpus, ensureDenyListData, exportRedactionKey, extractDefinedTerms, filterFalsePositives, findCoreferenceSpans, getCurrencyPatterns, getDatePatterns, getNameCorpusNonWesternNames, initAddressComponents, initHotwordRules, initNameCorpus, initZoneClassifier, levenshtein, mergeAndDedup, mergeChunkEntities, normalizeForSearch, prepareBatch, preparePipelineSearch, processAddressSeeds, processDenyListMatches, processGazetteerMatches, processLegalFormMatches, processRegexMatches, processTriggerMatches, propagateOrgNames, redactText, resolveCountries, resolveOperator, runPipeline, runUnifiedSearch, sanitizeEntities, tokenizeText, warmLegalRoleHeads };
16292
+ export { CURRENCY_PATTERN_META, DATE_PATTERN_META, DEFAULT_ENTITY_LABELS, DEFAULT_OPERATOR_CONFIG, DETECTION_SOURCES, DETECTOR_PRIORITY, OPERATOR_REGISTRY, OPERATOR_TYPES, REGEX_META, REGEX_PATTERNS, REGIONS, ZONE_SCORE_ADJUSTMENTS, applyHotwordRules, applyZoneAdjustments, boostNearMissEntities, buildDenyList, buildGazetteerPatterns, buildLegalFormPatterns, buildPlaceholderMap, buildStreetTypePatterns, buildTriggerPatterns, buildUnifiedSearch, chunkText, chunkTextWithOffsets, classifyZones, computeChunkOffsets, corefKey, createPipelineContext, deanonymise, decodeSpans, decodeTokenSpans, detectNameCorpus, ensureDenyListData, exportRedactionKey, extractDefinedTerms, filterFalsePositives, findCoreferenceSpans, getCurrencyPatterns, getDatePatterns, getNameCorpusNonWesternNames, initAddressComponents, initHotwordRules, initNameCorpus, initZoneClassifier, levenshtein, mergeAndDedup, mergeChunkEntities, normalizeForSearch, prepareBatch, preparePipelineSearch, processAddressSeeds, processDenyListMatches, processGazetteerMatches, processLegalFormMatches, processRegexMatches, processTriggerMatches, propagateOrgNames, redactText, resolveCountries, resolveOperator, runPipeline, runUnifiedSearch, sanitizeEntities, tokenizeText, warmLegalRoleHeads };
16105
16293
 
16106
16294
  //# sourceMappingURL=wasm.mjs.map