@stll/anonymize 0.0.4 → 0.0.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.
package/dist/index.d.ts CHANGED
@@ -204,7 +204,7 @@ type PipelineConfig = {
204
204
  * every pipeline run and never persisted to the database.
205
205
  * Renaming a label here requires no migration.
206
206
  */
207
- declare const DEFAULT_ENTITY_LABELS: readonly ["person", "organization", "phone number", "address", "email address", "date", "date of birth", "bank account number", "iban", "tax identification number", "identity card number", "registration number", "credit card number", "passport number", "monetary amount"];
207
+ declare const DEFAULT_ENTITY_LABELS: readonly ["person", "organization", "phone number", "address", "email address", "date", "date of birth", "bank account number", "iban", "tax identification number", "identity card number", "registration number", "credit card number", "passport number", "monetary amount", "land parcel"];
208
208
  //#endregion
209
209
  //#region src/detectors/regex.d.ts
210
210
  type RegexMeta = {
package/dist/index.js CHANGED
@@ -1,6 +1,10 @@
1
+ import { createRequire } from "node:module";
1
2
  import { at, be, bg, cy, cz, de, dk, ee, es, fi, fr, gb, gr, hr, hu, ie, it, lt, lu, lv, mt, nl, pl, pt, ro, se, si, sk } from "@stll/stdnum";
2
3
  import { toRegex } from "@stll/stdnum/patterns";
3
4
  import { TextSearch } from "@stll/text-search";
5
+ //#region \0rolldown/runtime.js
6
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
7
+ //#endregion
4
8
  //#region src/types.ts
5
9
  /**
6
10
  * Source of a detected entity span.
@@ -59,7 +63,8 @@ const DEFAULT_ENTITY_LABELS = [
59
63
  "registration number",
60
64
  "credit card number",
61
65
  "passport number",
62
- "monetary amount"
66
+ "monetary amount",
67
+ "land parcel"
63
68
  ];
64
69
  //#endregion
65
70
  //#region src/context.ts
@@ -140,6 +145,7 @@ const LOADER_REGISTRIES = {
140
145
  it: () => import("@stll/anonymize-data/config/triggers.it.json"),
141
146
  pl: () => import("@stll/anonymize-data/config/triggers.pl.json"),
142
147
  ro: () => import("@stll/anonymize-data/config/triggers.ro.json"),
148
+ sk: () => import("@stll/anonymize-data/config/triggers.sk.json"),
143
149
  sv: () => import("@stll/anonymize-data/config/triggers.sv.json")
144
150
  },
145
151
  coreference: {
@@ -160,6 +166,7 @@ const FALLBACK_LANGUAGES = {
160
166
  "it",
161
167
  "pl",
162
168
  "ro",
169
+ "sk",
163
170
  "sv"
164
171
  ],
165
172
  coreference: [
@@ -991,6 +998,68 @@ const POST_NOMINALS = [
991
998
  "CIPP"
992
999
  ];
993
1000
  //#endregion
1001
+ //#region src/util/char-groups.ts
1002
+ /** Chars that need escaping inside a regex char class. */
1003
+ const REGEX_CLASS_SPECIAL = /[\\\]^-]/;
1004
+ const escapeForCharClass = (ch) => REGEX_CLASS_SPECIAL.test(ch) ? `\\${ch}` : ch;
1005
+ let cached;
1006
+ const loadConfig = () => {
1007
+ if (cached) return cached;
1008
+ cached = __require("@stll/anonymize-data/config/char-groups.json");
1009
+ return cached;
1010
+ };
1011
+ /**
1012
+ * Get the raw characters for a named group.
1013
+ * Throws if the group does not exist.
1014
+ */
1015
+ const charSet = (group) => {
1016
+ const g = loadConfig().groups[group];
1017
+ if (!g) throw new Error(`Unknown char group: "${group}"`);
1018
+ return g.chars.map((entry) => entry.char);
1019
+ };
1020
+ /**
1021
+ * Build a regex character class string for a named
1022
+ * group. E.g., charClass("dash") returns a string
1023
+ * like "[-\u2013\u2014\u2010\u2011\u2212\u2043]".
1024
+ *
1025
+ * The hyphen-minus is placed first so it is treated
1026
+ * as a literal, not a range indicator.
1027
+ */
1028
+ const charClass = (group) => {
1029
+ return `[${[...charSet(group)].sort((a, b) => {
1030
+ if (a === "-") return -1;
1031
+ if (b === "-") return 1;
1032
+ return a.localeCompare(b);
1033
+ }).map(escapeForCharClass).join("")}]`;
1034
+ };
1035
+ /**
1036
+ * Return the inner content of a character class (without
1037
+ * the surrounding brackets). Useful for embedding a group
1038
+ * inside a larger character class, e.g.:
1039
+ * `[\\s&,.${charClassInner("dash")}]`
1040
+ */
1041
+ const charClassInner = (group) => {
1042
+ return [...charSet(group)].sort((a, b) => {
1043
+ if (a === "-") return -1;
1044
+ if (b === "-") return 1;
1045
+ return a.localeCompare(b);
1046
+ }).map(escapeForCharClass).join("");
1047
+ };
1048
+ /** Regex char class matching all dash variants. */
1049
+ const DASH = charClass("dash");
1050
+ /**
1051
+ * Inner content of the dash char class (no brackets).
1052
+ * For embedding inside a larger character class:
1053
+ * `[\\s&,.${DASH_INNER}]`
1054
+ */
1055
+ const DASH_INNER = charClassInner("dash");
1056
+ charClass("space");
1057
+ charClass("quote-double");
1058
+ charClass("quote-single");
1059
+ charClass("slash");
1060
+ charClass("dot");
1061
+ charClass("colon");
1062
+ //#endregion
994
1063
  //#region src/detectors/regex.ts
995
1064
  const MIN_PHONE_LENGTH = 7;
996
1065
  const MIN_MONTH_NAME_LENGTH = 3;
@@ -1328,9 +1397,9 @@ const buildCurrencyPatterns = (data) => {
1328
1397
  if (!symbols && !trailingAlt) return [];
1329
1398
  const NUM = "(?:\\d{1,3}(?:[,.'[^\\S\\n\\t]]\\d{3})+|\\d{1,9})";
1330
1399
  const patterns = [];
1331
- if (symbols) patterns.push(`(?:[${symbols}])[^\\S\\n\\t]?${NUM}(?:[.,](?:\\d{1,2}[-–—]?|[-–—]{1,2})?)?\\b`);
1332
- if (trailingAlt) patterns.push(`\\b(?:${trailingAlt})[^\\S\\n\\t]{0,2}${NUM}(?:[.,](?:\\d{1,2}[-–—]?|[-–—]{1,2})?)?\\b`);
1333
- if (trailingAlt) patterns.push(`\\b${NUM}(?:[.,](?:\\d{1,2}[-–—]?|[-–—]{1,2})?)?[^\\S\\n\\t]{0,4}(?:${trailingAlt})(?:\\b|(?=\\s|[.,;!?)]|$))`);
1400
+ if (symbols) patterns.push(`(?:[${symbols}])[^\\S\\n\\t]?${NUM}(?:[.,][^\\S\\n\\t]?(?:\\d{1,2}${DASH}?|${DASH}{1,2})?)?\\b`);
1401
+ if (trailingAlt) patterns.push(`\\b(?:${trailingAlt})[^\\S\\n\\t]{0,2}${NUM}(?:[.,][^\\S\\n\\t]?(?:\\d{1,2}${DASH}?|${DASH}{1,2})?)?\\b`);
1402
+ if (trailingAlt) patterns.push(`\\b${NUM}(?:[.,][^\\S\\n\\t]?(?:\\d{1,2}${DASH}?|${DASH}{1,2})?)?[^\\S\\n\\t]{0,4}(?:${trailingAlt})(?:\\b|(?=\\s|[.,;!?)]|$))`);
1334
1403
  return patterns;
1335
1404
  };
1336
1405
  /** Cached promise for currency patterns. Loaded once. */
@@ -1441,7 +1510,7 @@ const isShortForm = (form) => form.replace(/[.\s]/g, "").length <= 3 && !form.in
1441
1510
  const buildPatternString = (forms, requireCapBefore) => {
1442
1511
  if (forms.length === 0) return null;
1443
1512
  const alt = forms.toSorted((a, b) => b.length - a.length).map(escapeForRegex).join("|");
1444
- return `${`(?:${CAP_WORD})(?:(?:[\\s&,.\\-–—]{1,4}|\\s+(?:a|and|und|et|e|y|i)\\s+)(?:${ANY_WORD})){0,10}`}${requireCapBefore ? `(?:\\s+|,\\s*)` : `\\s+`}(?:${alt})(?![${LOWER}])`;
1513
+ return `${`(?:${CAP_WORD})(?:${`(?:[\\s&,.${DASH_INNER}]{1,4}|\\s+(?:a|and|und|et|e|y|i)\\s+)`}(?:${ANY_WORD})){0,10}`}${requireCapBefore ? `(?:\\s+|,\\s*)` : `\\s+`}(?:${alt})(?![${LOWER}])`;
1445
1514
  };
1446
1515
  /**
1447
1516
  * Build legal form regex pattern strings.
@@ -1470,7 +1539,7 @@ const buildLegalFormPatterns = async () => {
1470
1539
  if (longPattern) patterns.push(longPattern);
1471
1540
  const shortPattern = buildPatternString(allForms.filter(isShortForm), true);
1472
1541
  if (shortPattern) patterns.push(shortPattern);
1473
- const allcapPrefix = `(?:${ALLCAP_WORD})(?:[\\s&,.\\-–—]{1,4}(?:${ALLCAP_WORD})){0,2}`;
1542
+ const allcapPrefix = `(?:${ALLCAP_WORD})(?:[\\s&,.${DASH_INNER}]{1,4}(?:${ALLCAP_WORD})){0,2}`;
1474
1543
  const allcapAlt = allForms.toSorted((a, b) => b.length - a.length).map(escapeForRegex).join("|");
1475
1544
  patterns.push(`${allcapPrefix}(?:\\s+|,\\s*)(?:${allcapAlt})(?![${LOWER}])`);
1476
1545
  return patterns;
@@ -1566,6 +1635,11 @@ const TRIGGER_SCORE = .95;
1566
1635
  const WHITESPACE_RE$1 = /\s+/;
1567
1636
  const LETTER_RE = /\p{L}/u;
1568
1637
  /**
1638
+ * Decimal-comma pattern: comma followed by digit or
1639
+ * dash notation ("0,05%", "1.529,50 Kč", "98.000,- Kč").
1640
+ */
1641
+ const DECIMAL_COMMA_RE = new RegExp(`^,(?:\\d|${DASH}{1,2})`);
1642
+ /**
1569
1643
  * Post-nominal degree regex. When a comma-stop is
1570
1644
  * followed by a known post-nominal (Ph.D., CSc., MBA
1571
1645
  * etc.), skip the comma and degree, then continue.
@@ -1759,6 +1833,10 @@ const extractValue = (text, triggerEnd, strategy, label) => {
1759
1833
  }
1760
1834
  if (ch === ",") {
1761
1835
  const afterComma = valueText.slice(end);
1836
+ if (DECIMAL_COMMA_RE.test(afterComma)) {
1837
+ end++;
1838
+ continue;
1839
+ }
1762
1840
  const degreeMatch = label === "person" ? POST_NOMINAL_RE.exec(afterComma) : null;
1763
1841
  if (degreeMatch) {
1764
1842
  end += degreeMatch[0].length;
@@ -2243,6 +2321,7 @@ const filterFalsePositives = (entities, ctx = defaultContext) => {
2243
2321
  if (maxLen && trimmed.length > maxLen && entity.source !== "legal-form") continue;
2244
2322
  if (SECTION_NUMBER_RE.test(trimmed) && entity.source !== "trigger") continue;
2245
2323
  if (STANDALONE_YEAR_RE.test(trimmed)) continue;
2324
+ if (entity.label === "person" && HAS_DIGIT_RE.test(trimmed)) continue;
2246
2325
  if ((entity.label === "person" || entity.label === "organization") && roles.has(trimmed.toLowerCase())) continue;
2247
2326
  if (entity.label === "address" && trimmed.length > 40 && !POSTAL_CODE_RE.test(trimmed) && !HAS_DIGIT_RE.test(trimmed) && !ADDRESS_COMPONENTS_RE.test(trimmed)) continue;
2248
2327
  if (entity.label === "address" && entity.source === "trigger" && !HAS_DIGIT_RE.test(trimmed) && !ADDRESS_COMPONENTS_RE.test(trimmed)) continue;
@@ -2647,7 +2726,11 @@ const processDenyListMatches = (allMatches, sliceStart, sliceEnd, fullText, data
2647
2726
  if (!first || !last) continue;
2648
2727
  const extended = extendPersonName(fullText, first.start, last.end, ctx);
2649
2728
  const score = chain.length >= 2 ? .9 : .5;
2650
- if (chain.length === 1 && isSentenceStart(fullText, first.start)) continue;
2729
+ if (chain.length === 1 && isSentenceStart(fullText, first.start)) {
2730
+ const afterEnd = last.end;
2731
+ const rest = fullText.slice(afterEnd).trimStart();
2732
+ if (!(rest.length > 1 && /^\p{Lu}\p{Ll}/u.test(rest))) continue;
2733
+ }
2651
2734
  results.push({
2652
2735
  start: first.start,
2653
2736
  end: extended.end,
@@ -2661,7 +2744,7 @@ const processDenyListMatches = (allMatches, sliceStart, sliceEnd, fullText, data
2661
2744
  return results;
2662
2745
  };
2663
2746
  const DISTRICT_SUFFIX_RE = new RegExp(`^ (\\d{1,2}(?!\\d)|(?:XXX|XXIX|XXVIII|XXVII|XXVI|XXV|XXIV|XXIII|XXII|XXI|XX|XIX|XVIII|XVII|XVI|XV|XIV|XIII|XII|XI|X|IX|VIII|VII|VI|IV|III|II))(?=[\\s,;.)"\\n]|$)`);
2664
- const POSTAL_PREFIX_RE = /(?:\d{5}|\d{3}\s\d{2})\s+$/;
2747
+ const POSTAL_PREFIX_RE = new RegExp(`(?:\\d{5}|\\d{3}\\s\\d{2})\\s*${DASH}?\\s*$`);
2665
2748
  const TRAILING_WORD_EXCLUSIONS = new Set([
2666
2749
  "nájemce",
2667
2750
  "pronajímatel",
@@ -2715,7 +2798,7 @@ const extendCityDistricts = (entities, fullText) => {
2715
2798
  entity.text = fullText.slice(entity.start, entity.end);
2716
2799
  }
2717
2800
  const afterExt = fullText.slice(entity.end);
2718
- const trailingWordM = /^[\s]+(\p{Lu}\p{Ll}+)/u.exec(afterExt);
2801
+ const trailingWordM = /^[\s]{1,4}(\p{Lu}\p{Ll}+)/u.exec(afterExt);
2719
2802
  if (trailingWordM && !trailingWordM[0].includes("\n")) {
2720
2803
  const candidate = (trailingWordM[1] ?? "").toLowerCase();
2721
2804
  if (!TRAILING_WORD_EXCLUSIONS.has(candidate)) {
@@ -4135,11 +4218,21 @@ const unmaskNerEntities = (nerEntities, maskResult, fullText) => {
4135
4218
  };
4136
4219
  //#endregion
4137
4220
  //#region src/pipeline.ts
4221
+ /**
4222
+ * Sources backed by curated literal dictionaries.
4223
+ * Longer matches from these sources are more specific,
4224
+ * so the containment rule trusts their length.
4225
+ */
4226
+ const LITERAL_SOURCES = new Set(["deny-list", "gazetteer"]);
4138
4227
  const shouldReplace = (a, b) => {
4228
+ const aLen = a.end - a.start;
4229
+ const bLen = b.end - b.start;
4230
+ if (a.label === b.label && LITERAL_SOURCES.has(a.source) && a.start <= b.start && a.end >= b.end && aLen > bLen) return true;
4231
+ if (a.label === b.label && LITERAL_SOURCES.has(b.source) && b.start <= a.start && b.end >= a.end && bLen > aLen) return false;
4139
4232
  const aPri = DETECTOR_PRIORITY[a.source] ?? 0;
4140
4233
  const bPri = DETECTOR_PRIORITY[b.source] ?? 0;
4141
4234
  if (aPri !== bPri) return aPri > bPri;
4142
- return a.score > b.score || a.score === b.score && a.end - a.start > b.end - b.start;
4235
+ return a.score > b.score || a.score === b.score && aLen > bLen;
4143
4236
  };
4144
4237
  /** Labels where colons are structurally significant. */
4145
4238
  const COLON_LABELS = new Set(["ip address", "mac address"]);
@@ -4151,12 +4244,13 @@ const sanitizeEntities = (entities) => entities.flatMap((e) => {
4151
4244
  const cleaned = leadTrimmed.replace(new RegExp(`${strip.source}$`, strip.flags), "");
4152
4245
  if (cleaned.length === 0) return [];
4153
4246
  if (!/[\p{L}\p{N}]/u.test(cleaned)) return [];
4154
- if (cleaned === e.text) return [e];
4247
+ const collapsed = cleaned.replace(/\s*\n\s*/g, " ").replace(/\s{2,}/g, " ");
4248
+ if (collapsed === e.text) return [e];
4155
4249
  return [{
4156
4250
  ...e,
4157
4251
  start: e.start + lead,
4158
- end: e.start + lead + cleaned.length,
4159
- text: cleaned
4252
+ end: e.start + lead + collapsed.length,
4253
+ text: collapsed
4160
4254
  }];
4161
4255
  });
4162
4256
  const mergeAndDedup = (...layers) => {
@@ -4423,7 +4517,7 @@ const normalizeEntityText = (label, text) => {
4423
4517
  if (upper === "EMAIL_ADDRESS" || upper === "EMAIL") return text.toLowerCase().trim();
4424
4518
  if (upper === "PHONE_NUMBER" || upper === "PHONE") return text.replace(PHONE_NOISE_RE, "");
4425
4519
  if (upper === "IBAN" || upper === "BANK_ACCOUNT_NUMBER" || upper === "TAX_IDENTIFICATION_NUMBER" || upper === "REGISTRATION_NUMBER") return text.replace(SPACE_DASH_RE, "").toUpperCase();
4426
- if (upper === "PERSON" || upper === "ORGANIZATION" || upper === "ADDRESS") return text.replace(WHITESPACE_RE, " ").toLowerCase().trim();
4520
+ if (upper === "PERSON" || upper === "ORGANIZATION" || upper === "ADDRESS" || upper === "LAND_PARCEL") return text.replace(WHITESPACE_RE, " ").toLowerCase().trim();
4427
4521
  return text.trim();
4428
4522
  };
4429
4523
  /**