@stll/anonymize-wasm 1.4.10 → 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.
@@ -8304,6 +8313,17 @@ const NONWESTERN_LOCALE_KEYS = [
8304
8313
  "fil",
8305
8314
  "id"
8306
8315
  ];
8316
+ const NONWESTERN_NAME_IMPORTS = {
8317
+ in: () => import("./names-nw-in.mjs"),
8318
+ ar: () => import("./names-nw-ar.mjs"),
8319
+ "ja-latn": () => import("./names-nw-ja-latn.mjs"),
8320
+ ko: () => import("./names-nw-ko.mjs"),
8321
+ "zh-latn": () => import("./names-nw-zh-latn.mjs"),
8322
+ th: () => import("./names-nw-th.mjs"),
8323
+ vi: () => import("./names-nw-vi.mjs"),
8324
+ fil: () => import("./names-nw-fil.mjs"),
8325
+ id: () => import("./names-nw-id.mjs")
8326
+ };
8307
8327
  const normalizeCorpusLanguage = (language) => language.toLowerCase();
8308
8328
  const getScopedNonWesternLocaleKeys = (languages) => {
8309
8329
  if (languages === void 0) return NONWESTERN_LOCALE_KEYS;
@@ -8376,7 +8396,7 @@ const initNameCorpus = (ctx = defaultContext, dictionaries, languages) => {
8376
8396
  for (const form of scopedNonWesternHonorifics) if (form.endsWith(".")) titleAbbrSet.add(form.replace(/[.-]+$/u, "").toLowerCase());
8377
8397
  const exclusions = exclusionMod.default.words;
8378
8398
  const nwLocaleKeys = getScopedNonWesternLocaleKeys(languages);
8379
- const [nwNameMods, nwExcludedMod] = await Promise.all([Promise.all(nwLocaleKeys.map((locale) => import(`../data/names-nw-${locale}.json`))), import("./names-nw-excluded-allcaps.mjs")]);
8399
+ const [nwNameMods, nwExcludedMod] = await Promise.all([Promise.all(nwLocaleKeys.map((locale) => NONWESTERN_NAME_IMPORTS[locale]())), import("./names-nw-excluded-allcaps.mjs")]);
8380
8400
  const nonWesternNames = [];
8381
8401
  for (const mod of nwNameMods) for (const name of mod.default.names) nonWesternNames.push(name);
8382
8402
  if (dictionaries?.nonWesternNames) {
@@ -8394,6 +8414,7 @@ const initNameCorpus = (ctx = defaultContext, dictionaries, languages) => {
8394
8414
  titleTokens: Object.freeze(new Set(dedupTitles)),
8395
8415
  titleAbbreviations: Object.freeze(titleAbbrSet),
8396
8416
  excludedWords: Object.freeze(new Set(exclusions)),
8417
+ commonWords: Object.freeze(commonWords),
8397
8418
  nonWesternNames: Object.freeze(new Set(dedupNonWestern)),
8398
8419
  excludedAllCaps: Object.freeze(new Set(dedupExcludedAllCaps)),
8399
8420
  firstNamesList: Object.freeze(dedupFirst),
@@ -9108,13 +9129,14 @@ const detectNameCorpus = (fullText, ctx = defaultContext, options = {}) => {
9108
9129
  const corpusCount = chain.filter((t) => isCorpusMatch(t.type)).length;
9109
9130
  const capitalizedCount = chain.filter((t) => t.type === TOKEN_TYPE.CAPITALIZED).length;
9110
9131
  const nonWesternCount = chain.filter((t) => t.nonWestern).length;
9132
+ const chainAllCommonWords = chain.every((t) => corpus.commonWords.has(t.text.toLowerCase()));
9111
9133
  let score;
9112
9134
  if (hasNonWestern) {
9113
9135
  if (hasTitle && (nonWesternCount > 0 || capitalizedCount > 0)) score = .95;
9114
9136
  else if (hasJaSuffix && (capitalizedCount > 0 || nonWesternCount > 0)) score = .9;
9115
9137
  else if (hasArabicConnector && nonWesternCount > 0) score = .9;
9116
9138
  else if (nonWesternCount >= 2) score = .9;
9117
- else if (nonWesternCount > 0 && (capitalizedCount > 0 || hasAbbreviation)) score = .9;
9139
+ else if (nonWesternCount > 0 && (capitalizedCount > 0 || hasAbbreviation) && !chainAllCommonWords) score = .9;
9118
9140
  else if (nonWesternCount === 1 && chain.length === 1 && !isSentenceStart(fullText, token.start)) score = .5;
9119
9141
  else continue;
9120
9142
  if (supplementalMode && score < .9) continue;
@@ -9743,6 +9765,7 @@ var amount_words_default = {
9743
9765
  //#region src/detectors/regex.ts
9744
9766
  const MIN_PHONE_LENGTH = 7;
9745
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)";
9746
9769
  const escapeTitle = (title) => title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s*");
9747
9770
  /** Escape for use inside a regex alternation. */
9748
9771
  const escapeRegex$2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -9980,6 +10003,58 @@ const ES_CIF = {
9980
10003
  score: .95,
9981
10004
  validator: es.cif
9982
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
+ };
9983
10058
  const AU_ABN_FORMATTED = {
9984
10059
  pattern: `\\b\\d{2}[^\\S\\n]\\d{3}[^\\S\\n]\\d{3}[^\\S\\n]\\d{3}\\b`,
9985
10060
  label: "tax identification number",
@@ -10126,6 +10201,15 @@ const ALL_REGEX_DEFS = [
10126
10201
  ES_DNI,
10127
10202
  ES_NIE,
10128
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,
10129
10213
  AU_ABN_FORMATTED,
10130
10214
  NO_ORGNR_FORMATTED,
10131
10215
  NO_MVA_FORMATTED,
@@ -10157,6 +10241,7 @@ const REGEX_META = ALL_REGEX_DEFS.map((d) => {
10157
10241
  score: d.score
10158
10242
  };
10159
10243
  if (d.validator) meta.validator = d.validator;
10244
+ if (d.validatorInput) meta.validatorInput = d.validatorInput;
10160
10245
  return meta;
10161
10246
  });
10162
10247
  /**
@@ -10428,7 +10513,8 @@ const processRegexMatches = (allMatches, sliceStart, sliceEnd, meta_) => {
10428
10513
  if (!meta) continue;
10429
10514
  if (meta.sourceDetail !== "custom-regex" && meta.label === "phone number" && match.text.length < MIN_PHONE_LENGTH) continue;
10430
10515
  if (meta.validator) {
10431
- 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);
10432
10518
  if (!meta.validator.validate(compacted).valid) continue;
10433
10519
  }
10434
10520
  const entity = {
@@ -11884,7 +11970,40 @@ const trimTrailingAddressProse = (text) => {
11884
11970
  }
11885
11971
  return text;
11886
11972
  };
11887
- 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);
11888
12007
  let start = entity.start;
11889
12008
  let text = entity.text;
11890
12009
  const trimLeading = (re) => {
@@ -11902,10 +12021,13 @@ const normalizeEntity = (entity) => {
11902
12021
  const trailingMatch = /[,\s]+$/u.exec(text);
11903
12022
  if (trailingMatch) text = text.slice(0, text.length - trailingMatch[0].length);
11904
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;
11905
12027
  return {
11906
12028
  ...entity,
11907
12029
  start,
11908
- end: start + text.length,
12030
+ end,
11909
12031
  text
11910
12032
  };
11911
12033
  };
@@ -12010,7 +12132,6 @@ const initAddressComponents = () => {
12010
12132
  * Czech house number form.
12011
12133
  */
12012
12134
  const hasAddressComponent = (text) => _streetTypesRe.test(text) || ADDRESS_COMPONENT_EXTRA_RE.test(text);
12013
- const isCallerOwnedEntity$3 = (entity) => entity.sourceDetail === "custom-deny-list" || entity.sourceDetail === "custom-regex";
12014
12135
  /**
12015
12136
  * Filter out entities that are likely false positives:
12016
12137
  * template placeholders, clause/section numbers,
@@ -12023,14 +12144,14 @@ const filterFalsePositives = (entities, ctx = defaultContext, fullText) => {
12023
12144
  const filtered = [];
12024
12145
  const roles = getGenericRoles(ctx);
12025
12146
  for (const entity of entities) {
12026
- if (isCallerOwnedEntity$3(entity)) {
12147
+ if (isCallerOwnedEntity(entity)) {
12027
12148
  filtered.push(entity);
12028
12149
  continue;
12029
12150
  }
12030
- const normalized = normalizeEntity(entity);
12151
+ const normalized = normalizeEntity(entity, fullText);
12031
12152
  if (!normalized) continue;
12032
12153
  const trimmed = normalized.text;
12033
- if (isCallerOwnedEntity$3(normalized)) {
12154
+ if (isCallerOwnedEntity(normalized)) {
12034
12155
  filtered.push(normalized);
12035
12156
  continue;
12036
12157
  }
@@ -12109,6 +12230,32 @@ const loadCommonWords = () => {
12109
12230
  })();
12110
12231
  return commonWordsPromise;
12111
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
+ };
12112
12259
  /**
12113
12260
  * Curated dictionary entries that are pure dotted
12114
12261
  * single-letter acronyms (e.g. `S.C.`, `D.N.J.`, `C.E.C.`)
@@ -12422,10 +12569,12 @@ const buildDenyList = async (config, ctx = defaultContext) => {
12422
12569
  loadPersonStopwords(ctx),
12423
12570
  loadAddressStopwords(ctx),
12424
12571
  loadCommonWords(),
12572
+ loadMonthNames(),
12425
12573
  loadStreetTypeRe(),
12426
12574
  loadGenericRoles(ctx)
12427
12575
  ]);
12428
12576
  const commonWords = await loadCommonWords();
12577
+ const monthNames = await loadMonthNames();
12429
12578
  const dictionaries = config.dictionaries;
12430
12579
  const hasDenyList = dictionaries?.denyList && dictionaries?.denyListMeta;
12431
12580
  const hasCustomDenyList = config.customDenyList !== void 0 && config.customDenyList.length > 0;
@@ -12444,9 +12593,11 @@ const buildDenyList = async (config, ctx = defaultContext) => {
12444
12593
  const normalized = source === "custom-deny-list" ? normalizeForSearch(entry) : stripCuratedPatternSyntax(normalizeForSearch(entry));
12445
12594
  if (normalized.length === 0) return;
12446
12595
  const lower = normalized.toLowerCase();
12447
- if (source !== "custom-deny-list" && label !== "address") {
12448
- if (SINGLE_WORD_RE.test(normalized) && commonWords.has(lower)) return;
12449
- 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;
12450
12601
  }
12451
12602
  const existing = patternIndex.get(lower);
12452
12603
  if (existing !== void 0) {
@@ -13325,7 +13476,6 @@ const TRAILING_SEP = /[,\s]+$/;
13325
13476
  const WORD_CHAR_RE = /[\p{L}\p{N}]/u;
13326
13477
  const ORG_PROPAGATION_SCORE = .9;
13327
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;
13328
- const isCallerOwnedEntity$2 = (entity) => entity.sourceDetail === "custom-deny-list" || entity.sourceDetail === "custom-regex";
13329
13479
  /**
13330
13480
  * After the main detection pass, collect organization
13331
13481
  * entities with a legal form suffix, strip the suffix
@@ -13343,7 +13493,7 @@ const propagateOrgNames = (entities, fullText) => {
13343
13493
  const seedByBase = /* @__PURE__ */ new Map();
13344
13494
  for (const e of entities) {
13345
13495
  if (e.label !== "organization") continue;
13346
- if (isCallerOwnedEntity$2(e)) continue;
13496
+ if (isCallerOwnedEntity(e)) continue;
13347
13497
  for (const suffix of LEGAL_SUFFIXES) if (e.text.endsWith(suffix)) {
13348
13498
  const base = e.text.slice(0, -suffix.length).replace(TRAILING_SEP, "").trim();
13349
13499
  if (base.length >= 3) {
@@ -13713,7 +13863,6 @@ const NEAR_MISS_BAND = .15;
13713
13863
  const BOOST_PER_NEIGHBOUR = .05;
13714
13864
  const CONTEXT_WINDOW_CHARS = 150;
13715
13865
  const HIGH_CONFIDENCE_FLOOR = .9;
13716
- const isCallerOwnedEntity$1 = (entity) => entity.sourceDetail === "custom-deny-list" || entity.sourceDetail === "custom-regex";
13717
13866
  /**
13718
13867
  * Boost confidence of near-miss NER entities that appear
13719
13868
  * near high-confidence detections (regex, trigger phrase).
@@ -13745,7 +13894,7 @@ const boostNearMissEntities = (entities, threshold) => {
13745
13894
  const boostedScore = entity.score + neighbourCount * BOOST_PER_NEIGHBOUR;
13746
13895
  if (boostedScore >= threshold) boosted.push({
13747
13896
  ...entity,
13748
- score: boostedScore
13897
+ score: Math.min(1, boostedScore)
13749
13898
  });
13750
13899
  }
13751
13900
  return boosted;
@@ -13824,7 +13973,7 @@ const getStreetAbbrevs = () => _streetAbbrevs ?? /* @__PURE__ */ new Set();
13824
13973
  */
13825
13974
  const detectStreetPatternsNearAddresses = (fullText, existingEntities) => {
13826
13975
  const results = [];
13827
- 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");
13828
13977
  const headerEnd = Math.floor(fullText.length * HEADER_ZONE_FRACTION);
13829
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;
13830
13979
  houseNumRe.lastIndex = 0;
@@ -13923,7 +14072,7 @@ const ORPHAN_STREET_RE = /^[^\S\n]*(\p{Lu}[\p{Ll}\p{Lu}]+(?:[^\S\n]+[\p{Lu}\p{Ll
13923
14072
  */
13924
14073
  const detectOrphanStreetLines = (fullText, existingEntities) => {
13925
14074
  const headerEnd = Math.floor(fullText.length * HEADER_ZONE_FRACTION);
13926
- const contextEntities = existingEntities.filter((entity) => !(entity.label === "address" && isCallerOwnedEntity$1(entity)));
14075
+ const contextEntities = existingEntities.filter((entity) => !(entity.label === "address" && isCallerOwnedEntity(entity)));
13927
14076
  const results = [];
13928
14077
  ORPHAN_STREET_RE.lastIndex = 0;
13929
14078
  for (let m = ORPHAN_STREET_RE.exec(fullText); m !== null; m = ORPHAN_STREET_RE.exec(fullText)) {
@@ -14281,7 +14430,7 @@ const applyHotwordRules = (entities, fullText) => {
14281
14430
  //#region src/filters/boundary-consistency.ts
14282
14431
  /** Max gap (in chars) between entities to merge. */
14283
14432
  const MAX_GAP = 3;
14284
- const hasLockedBoundary$1 = (entity) => entity.sourceDetail === "custom-deny-list" || entity.sourceDetail === "custom-regex";
14433
+ const hasLockedBoundary$1 = (entity) => isCallerOwnedEntity(entity);
14285
14434
  const hasDetectorLockedBoundary = (entity) => entity.label === "phone number" && entity.source === DETECTION_SOURCES.TRIGGER;
14286
14435
  /**
14287
14436
  * Characters allowed in the gap between two adjacent
@@ -14864,7 +15013,6 @@ const unmaskNerEntities = (nerEntities, maskResult, fullText) => {
14864
15013
  * so the containment rule trusts their length.
14865
15014
  */
14866
15015
  const LITERAL_SOURCES = new Set(["deny-list", "gazetteer"]);
14867
- const isCallerOwnedEntity = (entity) => entity.sourceDetail === "custom-deny-list" || entity.sourceDetail === "custom-regex";
14868
15016
  const hasLockedBoundary = (entity) => isCallerOwnedEntity(entity);
14869
15017
  const isCoveredByDenyListEntity = (entity, denyListEntity) => entity.label === denyListEntity.label && denyListEntity.start <= entity.start && denyListEntity.end >= entity.end;
14870
15018
  const filterNameCorpusCoveredByDenyList = (nameCorpusEntities, denyListEntities) => nameCorpusEntities.filter((entity) => !denyListEntities.some((denyListEntity) => isCoveredByDenyListEntity(entity, denyListEntity)));
@@ -15088,7 +15236,7 @@ const sanitizeEntities = (entities) => entities.flatMap((e) => {
15088
15236
  return [{
15089
15237
  ...e,
15090
15238
  start: e.start + lead,
15091
- end: e.start + lead + collapsed.length,
15239
+ end: e.start + lead + cleaned.length,
15092
15240
  text: collapsed
15093
15241
  }];
15094
15242
  });
@@ -15204,7 +15352,7 @@ const filterAllowedLabels = (entities, allowedLabels) => {
15204
15352
  return entities.filter((e) => allowedLabels.has(e.label));
15205
15353
  };
15206
15354
  const labelIsAllowed = (label, allowedLabels) => !allowedLabels || allowedLabels.has(label);
15207
- const NON_NER_LABELS = new Set(["misc"]);
15355
+ const NON_NER_LABELS = new Set(["crypto", "misc"]);
15208
15356
  const getRequestedNerLabels = (config, expandForHotwords = false) => {
15209
15357
  const labels = config.labels.length > 0 ? config.labels : DEFAULT_ENTITY_LABELS;
15210
15358
  return (expandForHotwords ? expandLabelsForHotwordRules(labels) : labels).filter((label) => !NON_NER_LABELS.has(label));
@@ -15534,7 +15682,23 @@ const resolveOperator = (config, label) => config.operators[label] ?? "replace";
15534
15682
  //#region src/redact.ts
15535
15683
  const WHITESPACE_RE = /\s+/g;
15536
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/;
15537
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
+ };
15538
15702
  /**
15539
15703
  * Normalize entity text so that surface-form variations
15540
15704
  * of the same real-world value map to a single canonical
@@ -15544,7 +15708,10 @@ const normalizeEntityText = (label, text) => {
15544
15708
  const upper = label.toUpperCase().replace(WHITESPACE_RE, "_");
15545
15709
  if (upper === "EMAIL_ADDRESS" || upper === "EMAIL") return text.toLowerCase().trim();
15546
15710
  if (upper === "PHONE_NUMBER" || upper === "PHONE") return text.replace(PHONE_NOISE_RE, "");
15547
- 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);
15548
15715
  if (upper === "PERSON" || upper === "ORGANIZATION" || upper === "ADDRESS" || upper === "LAND_PARCEL" || upper === "MISC") return text.replace(WHITESPACE_RE, " ").toLowerCase().trim();
15549
15716
  return text.trim();
15550
15717
  };
@@ -15677,7 +15844,7 @@ const hasOverlapping = (a, b, multiLabel) => {
15677
15844
  const b0 = b[0] ?? 0;
15678
15845
  const b1 = b[1] ?? 0;
15679
15846
  if (a0 === b0 && a1 === b1) return !multiLabel;
15680
- return !(a0 > b1 || b0 > a1);
15847
+ return a0 < b1 && b0 < a1;
15681
15848
  };
15682
15849
  /**
15683
15850
  * Greedy non-overlapping span selection.
@@ -15889,7 +16056,12 @@ const encodeInputs = (tokenizer, texts, promptLengths) => {
15889
16056
  allWordsMasks
15890
16057
  ];
15891
16058
  };
15892
- /** 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
+ */
15893
16065
  const prepareSpans = (batchTokens, maxWidth) => {
15894
16066
  const spanIdxs = [];
15895
16067
  const spanMasks = [];
@@ -15900,7 +16072,7 @@ const prepareSpans = (batchTokens, maxWidth) => {
15900
16072
  for (let i = 0; i < len; i++) for (let j = 0; j < maxWidth; j++) {
15901
16073
  const endIdx = Math.min(i + j, len - 1);
15902
16074
  idx.push([i, endIdx]);
15903
- mask.push(endIdx < len);
16075
+ mask.push(i + j < len);
15904
16076
  }
15905
16077
  spanIdxs.push(idx);
15906
16078
  spanMasks.push(mask);
@@ -15966,13 +16138,20 @@ const MAX_CHUNK_CHARS = 1500;
15966
16138
  const OVERLAP_CHARS = 50;
15967
16139
  const MIN_CHUNK_LENGTH = 10;
15968
16140
  /**
15969
- * Split text into overlapping chunks for GLiNER's
15970
- * ~512 token context window. Character-based splitting
15971
- * (rough approximation of token limits).
16141
+ * Split text into overlapping chunks, each paired with its
16142
+ * exact start offset in the source text.
15972
16143
  *
15973
- * Tries to break at sentence boundaries when possible.
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).
16149
+ *
16150
+ * Character-based splitting (rough token approximation for
16151
+ * GLiNER's ~512 token window); breaks at sentence boundaries
16152
+ * when possible.
15974
16153
  */
15975
- const chunkText = (text) => {
16154
+ const chunkTextWithOffsets = (text) => {
15976
16155
  const chunks = [];
15977
16156
  let offset = 0;
15978
16157
  while (offset < text.length) {
@@ -15982,14 +16161,34 @@ const chunkText = (text) => {
15982
16161
  if (lastPeriod > MAX_CHUNK_CHARS * .5) end = offset + lastPeriod + 2;
15983
16162
  }
15984
16163
  const chunk = text.slice(offset, end);
15985
- 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;
15986
16169
  offset = Math.max(offset + 1, end - OVERLAP_CHARS);
15987
16170
  }
15988
16171
  return chunks;
15989
16172
  };
15990
16173
  /**
15991
- * Compute the byte offset of each chunk within the
15992
- * 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.
15993
16192
  */
15994
16193
  const computeChunkOffsets = (fullText, chunks) => {
15995
16194
  const offsets = [];
@@ -16090,6 +16289,6 @@ const levenshtein = (rawA, rawB) => {
16090
16289
  //#region src/wasm.ts
16091
16290
  initTextSearch(TextSearch);
16092
16291
  //#endregion
16093
- 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 };
16094
16293
 
16095
16294
  //# sourceMappingURL=wasm.mjs.map