euredact 0.3.8 → 0.4.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.
@@ -34,18 +34,34 @@
34
34
  * the *tail* of an ordinary word. "NIS" matched "tālru**nis**", the Latvian
35
35
  * for telephone, and suppressed 653 real phone numbers; "iva" matched
36
36
  * "Pr**iva**t:" and read a personal e-mail address as a VAT number.
37
- * * The qualifier class is spelled out rather than written `[^\W\d_]`, which
38
- * would reject "εταιρειας" here for the same ASCII reason.
37
+ * * Neither the qualifier nor the run-on may use `\w`. The qualifier is a
38
+ * negated ASCII class, fixed for this in 0.3.8; `[^\W\d_]` would reject
39
+ * "εταιρειας" here for the same ASCII reason. The run-on was *not* fixed then
40
+ * and went on diverging for another release: Slovak "telefón 0956550012"
41
+ * needs it to absorb "efón" after `tel`, and Bulgarian
42
+ * "пощенски кодове 4000-4999" needs "ове" after `пощенски код`. Python's
43
+ * Unicode `\w` did; this SDK's ASCII `\w` did not, so one engine said `PHONE`
44
+ * and the other `NATIONAL_ID` on the same document. They were the last two
45
+ * type divergences on the corpus, and only the type-aware half of
46
+ * `make parity` could see them.
39
47
  *
40
- * Every label ends in `(?:\w*|\s+<one word>)`: the label either runs on into a
41
- * longer word ("telefoon" → "telefoonnummer") **or** takes one qualifier word
42
- * naming whose identifier it is ("ΑΦΜ εταιρειας:", "Tel Nr:") — never both.
48
+ * The run-on is spelled `(?:[A-Za-z0-9_]|[^\x00-\x7F])` — an ASCII word
49
+ * character, or any non-ASCII character — rather than as a negated class of
50
+ * separators. A negated class also admits `'`, `%`, `&`, `@` and the rest of
51
+ * ASCII punctuation, which `\w` never did, and widening it that far was
52
+ * measured at 46 extra false positives with hints and 57 blind over the
53
+ * 152,300-document corpus. This is `\w` plus the non-ASCII letters `\w`
54
+ * should have matched all along, and nothing else.
55
+ *
56
+ * Every label ends in `(?:<run-on>|\s+<one word>)`: the label either runs on
57
+ * into a longer word ("telefoon" → "telefoonnummer") **or** takes one qualifier
58
+ * word naming whose identifier it is ("ΑΦΜ εταιρειας:", "Tel Nr:") — never both.
43
59
  * Allowing both let "nie" grow into the surname "Nieminen" and then swallow the
44
60
  * real label in "Matti Nieminen\nDOB: 08.05.1994", reading a date of birth as a
45
61
  * Spanish NIE: 52 false positives across the corpus.
46
62
  */
47
63
  Object.defineProperty(exports, "__esModule", { value: true });
48
- exports.CUE_TARGETS = exports.CUES = exports.CUE_WINDOW = void 0;
64
+ exports.RESCUE_TARGETS = exports.CUE_TARGETS = exports.CUES = exports.CUE_WINDOW = void 0;
49
65
  exports.cuedType = cuedType;
50
66
  exports.retypedBy = retypedBy;
51
67
  const types_js_1 = require("../types.js");
@@ -54,7 +70,7 @@ const types_js_1 = require("../types.js");
54
70
  * the value it introduces, which is the lesson of the postal-code year defect,
55
71
  * where a cue 150 characters away licensed every number in the window.
56
72
  */
57
- exports.CUE_WINDOW = 22;
73
+ exports.CUE_WINDOW = 32;
58
74
  /**
59
75
  * (entity type, label pattern). Ordered: the first match wins, so the narrower
60
76
  * label comes first where two could read the same text.
@@ -64,46 +80,63 @@ exports.CUES = [
64
80
  // front of a span that no phone pattern matched says the document is laid out
65
81
  // unusually, not that the value is a phone number.
66
82
  [types_js_1.EntityType.PHONE,
67
- /(?<![A-Za-z0-9_])(?:phone|tel|telephone|téléphone|telefoon|telefon|telefono|teléfono|tlf|mobil|mobile|gsm|handy|sími|puh|móvil|cell|tālrunis|τηλέφωνο|τηλ)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
83
+ /(?<![A-Za-z0-9_])(?:phone|tel|telephone|téléphone|telefoon|telefon|telefono|teléfono|tlf|mobil|mobile|gsm|handy|sími|puh|móvil|cell|tālrunis|τηλέφωνο|τηλ)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
68
84
  // National identity number.
69
85
  [types_js_1.EntityType.NATIONAL_ID,
70
- /(?<![A-Za-z0-9_])(?:bsn|personnummer|rijksregisternummer|nationaal\s*nummer|numéro\s*national|national\s*(?:id|number)|identiteitsnummer|henkilötunnus|kennitala|cpr(?:-?nummer)?|nif|nie|dni|rr|nn|nir|insz|niss|nis|matricule|ausweisnummer|personalausweis|osobní\s*číslo|ЕГН|EGN)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
86
+ /(?<![A-Za-z0-9_])(?:bsn|burgerservicenummer(?:\s*\(bsn\))?|personnummer|rijksregisternummer|nationaal\s*nummer|numéro\s*national|national\s*(?:id|number)|identiteitsnummer|henkilötunnus|kennitala|cpr(?:-?nummer)?|nif|nie|dni|rr|nn|nir|insz|niss|nis|matricule|ausweisnummer|personalausweis|osobní\s*číslo|ЕГН|EGN)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
71
87
  // Cyprus: "ID number" in both official languages, plus the Greek abbreviation
72
88
  // ΑΔΤ (Αστυνομική Ταυτότητα). Neither has a phone reading.
73
89
  [types_js_1.EntityType.NATIONAL_ID,
74
- /(?<![A-Za-z0-9_])(?:αρ\.?\s*ταυτότητας|ταυτότητας|αδτ|kimlik\s*numaras[ıi])(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
90
+ /(?<![A-Za-z0-9_])(?:αρ\.?\s*ταυτότητας|ταυτότητας|αδτ|kimlik\s*numaras[ıi])(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
75
91
  // Social security.
76
92
  [types_js_1.EntityType.SSN,
77
- /(?<![A-Za-z0-9_])(?:ssn|social\s*security|sozialversicherungsnummer|svnr|sofi)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
93
+ /(?<![A-Za-z0-9_])(?:ssn|social\s*security|sozialversicherungsnummer|svnr|sofi)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
78
94
  // Tax number. Greece's ΑΦΜ is issued by the tax authority; the identity-card
79
- // number is the ΑΔΤ, above. The engine used to return NATIONAL_ID for a value
80
- // cued ΑΦΜ, which is wrong independently of the phone problem.
95
+ // number is the ΑΔΤ, above.
81
96
  [types_js_1.EntityType.TAX_ID,
82
- /(?<![A-Za-z0-9_])(?:αφμ|α\.φ\.μ\.|steuer-?id|steuernummer|st\.?-?nr|stnr|tin|finanzamt\s+ist|tax\s*(?:id|no|number)|daňové\s*číslo|numer\s*podatkowy)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
83
- // Health insurance.
97
+ /(?<![A-Za-z0-9_])(?:αφμ|α\.φ\.μ\.|steuer-?id|steuernummer|st\.?-?nr|stnr|tin|finanzamt\s+ist|tax\s*(?:id|no|number)|daňové\s*číslo|numer\s*podatkowy)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
98
+ // Health insurance: the insured person.
84
99
  [types_js_1.EntityType.HEALTH_INSURANCE,
85
- /(?<![A-Za-z0-9_])(?:versichertennummer|krankenversicherung|kvnr|kv-?nr|mutualité|ziekenfonds|aansluitingsnummer)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
86
- // Company / trade register.
100
+ /(?<![A-Za-z0-9_])(?:versichertennummer|krankenversicherung|kvnr|kv-?nr|mutualité|ziekenfonds|aansluitingsnummer|medical\s*card(?:\s*(?:no|number))?)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
101
+ // Healthcare provider: the clinician or practice, not the patient — the Dutch
102
+ // AGB-code, the German LANR, the UK GMC number.
103
+ [types_js_1.EntityType.HEALTHCARE_PROVIDER,
104
+ /(?<![A-Za-z0-9_])(?:agb-?code|lanr|bsnr|gmc(?:\s*(?:no|number|reg))?|big-?nummer|zorgverlener(?:snummer)?)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
105
+ // Company / trade register. "Companies House" and "Company Registration
106
+ // Number" are written out: the tail allows a run-on *or* one qualifier word,
107
+ // never a second word, so a three-word label needs to be spelled.
87
108
  [types_js_1.EntityType.CHAMBER_OF_COMMERCE,
88
- /(?<![A-Za-z0-9_])(?:siren|siret|kbo|bce|kvk|ondernemingsnummer|ondernemingen\s+onder\s+nummer|kruispuntbank|numéro\s*d'entreprise|enterprise\s*number|handelsregister|company\s*(?:no|number|reg)|organisationsnummer|orgnr|i[čc]o|identifikační\s*číslo|ЕИК|eik|bulstat|cvr(?:-?nummer)?|virksomhedsnummer)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
109
+ /(?<![A-Za-z0-9_])(?:siren|siret|kbo|bce|kvk|ondernemingsnummer|ondernemingen\s+onder\s+nummer|kruispuntbank|numéro\s*d'entreprise|enterprise\s*number|handelsregister|companies\s*house(?:\s*(?:registration|reg|no|number))?|company\s*registration(?:\s*(?:number|no))?|company\s*(?:no|number|reg)|organisationsnummer|orgnr|i[čc]o|identifikační\s*číslo|ЕИК|eik|bulstat|cvr(?:-?nummer)?|virksomhedsnummer)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
89
110
  // VAT.
90
111
  [types_js_1.EntityType.VAT,
91
- /(?<![A-Za-z0-9_])(?:vat|btw|tva|ust|iva|moms|alv|mwst|dič|vsk)(?:[\w.\-]*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
112
+ /(?<![A-Za-z0-9_])(?:vat|btw|tva|ust|iva|moms|alv|mwst|dič|vsk)(?:(?:[A-Za-z0-9_.\-]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
113
+ // Bank account. A UK sort code is NN-NN-NN, also a licence-plate shape, so
114
+ // "sort code 20-45-91" was typed LICENSE_PLATE with the label in front of it.
115
+ // Retype target only — a failed mod-97 is never rescued by a label.
116
+ [types_js_1.EntityType.BANK_ACCOUNT,
117
+ /(?<![A-Za-z0-9_])(?:sort\s*code|account\s*(?:no|number)|rekeningnummer|kontonummer|numéro\s*de\s*compte|sort-?code)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
118
+ // Passport.
119
+ [types_js_1.EntityType.PASSPORT,
120
+ /(?<![A-Za-z0-9_])(?:passport(?:\s*(?:no|number))?|paspoort(?:nummer)?|reisepass(?:nummer)?|passeport|passnummer)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
92
121
  // BIC / SWIFT.
93
122
  [types_js_1.EntityType.BIC,
94
- /(?<![A-Za-z0-9_])(?:bic|swift)[\w/]*\s*[:.\-]?\s*\(?\s*$/i],
123
+ /(?<![A-Za-z0-9_])(?:bic|swift)(?:[A-Za-z0-9_\/]|[^\x00-\x7F])*\s*[:.\-]?\s*\(?\s*$/i],
95
124
  // Postal code. A postal code loses every contested span on priority (it is
96
125
  // the weakest evidence the engine has), so without a cue entry
97
126
  // "Cod postal: 040171" was handed to a German phone pattern.
98
127
  [types_js_1.EntityType.POSTAL_CODE,
99
- /(?<![A-Za-z0-9_])(?:cod\s*po[sș]tal|postnummer|postnr|postleitzahl|plz|postcode|code\s*postal|codice\s*postale|kod\s*pocztowy|ps[čc]|τ\.?κ\.?|пощенски\s*код)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
128
+ /(?<![A-Za-z0-9_])(?:cod\s*po[sș]tal|postnummer|postnr|postleitzahl|plz(?:-?bereiche?)?|postcode|code\s*postal|codice\s*postale|kod\s*pocztowy|ps[čc]|τ\.?κ\.?|пощенски\s*код)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
100
129
  // Internal / employee identifier. The canon calls these INTERNAL_ID and
101
130
  // annotates them with an LLM, so the engine has no pattern for them — but it
102
131
  // does have a phone pattern that was happily claiming them. The cue is the
103
132
  // only thing that identifies one, which is why the engine emits this type
104
133
  // from here and nowhere else.
105
134
  [types_js_1.EntityType.INTERNAL_ID,
106
- /(?<![A-Za-z0-9_])(?:medarbejdernummer|ansattnummer|anställningsnummer|personalnummer|personeelsnummer|matricule\s*salarié|employee\s*(?:no|number|id)|badge\s*(?:no|number)|osobní\s*(?:číslo\s*)?zaměstnance)(?:\w*|\s+[^\s:.\-\d,;()/]{2,20})\s*[:.\-]?\s*$/i],
135
+ /(?<![A-Za-z0-9_])(?:medarbejdernummer|ansattnummer|anställningsnummer|personalnummer|personeelsnummer|matricule\s*salarié|employee\s*(?:no|number|id)|badge(?:\s*(?:no|number))?|betriebsstätten(?:nr|nummer)|grundstücks(?:nr|nummer)|dossier-?(?:nr|nummer)|aktenzeichen|osobní\s*(?:číslo\s*)?zaměstnance)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
136
+ // Credential. Typed SECRET because that is the type this SDK has; the
137
+ // training pipeline's report asks for CREDENTIAL, which does not exist here.
138
+ [types_js_1.EntityType.SECRET,
139
+ /(?<![A-Za-z0-9_])(?:tan-?activatiecode|activatiecode|activation\s*code|verificatiecode|verification\s*code|otp|pincode)(?:(?:[A-Za-z0-9_]|[^\x00-\x7F])*|\s+[^\s:.\-\d,;()\/]{2,20})\s*\)?\s*[:.\-]*\s*$/i],
107
140
  ];
108
141
  /**
109
142
  * The entity type a label immediately before `start` names, or null.
@@ -129,22 +162,56 @@ const RETYPABLE = new Set([
129
162
  types_js_1.EntityType.PHONE, types_js_1.EntityType.POSTAL_CODE, types_js_1.EntityType.LICENSE_PLATE,
130
163
  ]);
131
164
  /**
132
- * Types a label can assert on its own. Also the types eligible for the rescue
133
- * in `engine.ts`, for the same reason read the other way — a label may vouch
134
- * for one of these, so it may also vouch for a malformed one.
165
+ * Retypable, but only when the document gives its country no support at all.
166
+ *
167
+ * These carry a checksum, so normally the pattern outranks any label. But a
168
+ * checksum only says the digits fit *some* national scheme, and a weak one fits
169
+ * by luck — the same reasoning that demotes an uncorroborated validated
170
+ * candidate to priority 1 in `engine.ts`. At a country score of 0.0 the engine
171
+ * is already saying "this passed a Czech checksum in a document with nothing
172
+ * Czech about it", and an explicit "Passport No.:" is the better evidence.
173
+ *
174
+ * Restricted to zero country support on purpose: a Dutch BSN in a Dutch
175
+ * document scores above zero and stays a NATIONAL_ID whatever label sits near
176
+ * it, so this cannot relabel a domestic identifier — only the coincidental
177
+ * foreign ones, which is where "Passport No.: 512847603" and
178
+ * "Betriebsstättennr. 260326822" both landed, each reported as Czech.
179
+ */
180
+ const RETYPABLE_UNCORROBORATED = new Set([types_js_1.EntityType.NATIONAL_ID]);
181
+ /**
182
+ * Types a label can assert on its own.
135
183
  *
136
184
  * PHONE is absent on purpose: "Tel:" in front of something no phone pattern
137
185
  * matched means the document is laid out oddly, not that the value is a phone
138
- * number. So are EMAIL, SECRET, DOB, VIN and the rest — those carry their own
186
+ * number. So are EMAIL, DOB, VIN and the rest — those carry their own
139
187
  * structure, and a nearby word is not entitled to overrule it.
188
+ */
189
+ exports.CUE_TARGETS = new Set([
190
+ types_js_1.EntityType.NATIONAL_ID, types_js_1.EntityType.SSN, types_js_1.EntityType.TAX_ID,
191
+ types_js_1.EntityType.HEALTH_INSURANCE, types_js_1.EntityType.HEALTHCARE_PROVIDER,
192
+ types_js_1.EntityType.CHAMBER_OF_COMMERCE, types_js_1.EntityType.VAT, types_js_1.EntityType.POSTAL_CODE,
193
+ types_js_1.EntityType.PASSPORT, types_js_1.EntityType.INTERNAL_ID, types_js_1.EntityType.BANK_ACCOUNT,
194
+ types_js_1.EntityType.SECRET,
195
+ ]);
196
+ /**
197
+ * Which of those a label may also *rescue* — re-admit after its checksum
198
+ * failed. Until 0.3.9 this was the same set as CUE_TARGETS and it cannot stay
199
+ * that way: re-typing a span the engine already decided to mask is cheap to be
200
+ * wrong about, while rescuing one asserts that a value which failed its check
201
+ * digit is a real identifier anyway.
140
202
  *
141
203
  * BIC's absence is what keeps the rescue honest. Its validator is a *registry
142
204
  * lookup*, not a checksum, so a failure means "no such bank", which no label can
143
205
  * talk you out of. Rescuing it read 34 unlisted codes behind a "BIC:" label as
144
206
  * real ones. The same reasoning keeps CREDIT_CARD and BANK_ACCOUNT out: Luhn and
145
207
  * mod-97 are strong enough that a failure really does mean "not one of these".
208
+ * BANK_ACCOUNT joined CUE_TARGETS in 0.3.9 so "sort code 20-45-91" stops being a
209
+ * LICENSE_PLATE, and is kept out here for exactly the 0.3.8 reason.
210
+ *
211
+ * HEALTHCARE_PROVIDER and SECRET are absent because neither is checksummed;
212
+ * there is no failed validation for a label to overrule in the first place.
146
213
  */
147
- exports.CUE_TARGETS = new Set([
214
+ exports.RESCUE_TARGETS = new Set([
148
215
  types_js_1.EntityType.NATIONAL_ID, types_js_1.EntityType.SSN, types_js_1.EntityType.TAX_ID,
149
216
  types_js_1.EntityType.HEALTH_INSURANCE, types_js_1.EntityType.CHAMBER_OF_COMMERCE,
150
217
  types_js_1.EntityType.VAT, types_js_1.EntityType.POSTAL_CODE, types_js_1.EntityType.PASSPORT,
@@ -157,9 +224,14 @@ exports.CUE_TARGETS = new Set([
157
224
  * without them fired 52 times and got roughly 30 of those wrong — EMAIL read as
158
225
  * VAT, a date of birth as a phone number, a VIN as VAT — against 20 real fixes.
159
226
  * With them: 14 re-typings, every one of them PHONE to a structured identifier.
227
+ *
228
+ * `countryScore` is the document's support for the country the winning pattern
229
+ * came from; see RETYPABLE_UNCORROBORATED.
160
230
  */
161
- function retypedBy(text, start, entityType) {
162
- if (!RETYPABLE.has(String(entityType)))
231
+ function retypedBy(text, start, entityType, countryScore) {
232
+ const type = String(entityType);
233
+ if (!RETYPABLE.has(type)
234
+ && !(RETYPABLE_UNCORROBORATED.has(type) && countryScore === 0))
163
235
  return null;
164
236
  const cued = cuedType(text, start);
165
237
  if (cued === null || cued === entityType || !exports.CUE_TARGETS.has(String(cued)))
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.RuleEngine = exports.COUNTRY_CODE_ALIASES = void 0;
37
37
  exports.resolveCountryCode = resolveCountryCode;
38
+ exports.tieKey = tieKey;
38
39
  const types_js_1 = require("../types.js");
39
40
  const matchers_js_1 = require("./matchers.js");
40
41
  const suppressors_js_1 = require("./suppressors.js");
@@ -81,6 +82,48 @@ function resolveCountryCode(countryCode) {
81
82
  code = exports.COUNTRY_CODE_ALIASES[code] ?? code;
82
83
  return code in index_js_1.COUNTRY_CONFIGS && code !== "SHARED" ? code : null;
83
84
  }
85
+ /**
86
+ * Last-resort ordering for candidates every other field has tied on.
87
+ *
88
+ * Reaching this means the engine has no evidence left: same span, no cue, same
89
+ * priority, the same (usually zero) country score, and the same declared scope.
90
+ * Something still has to win, and before this the winner was whichever candidate
91
+ * happened to be generated first — which is country *registration* order, and
92
+ * the two SDKs do not share one. Python registers alphabetically
93
+ * (`AT, BE, BG, CH, CY, CZ, ...`); this SDK uses a curated order
94
+ * (`NL, BE, DE, AT, CH, FR, ..., PT, LU, PL, ..., CZ`). So a bare digit run that
95
+ * several national schemes accept was filed as a Czech `NATIONAL_ID` by Python
96
+ * and a Portuguese `PHONE` here — identical characters masked, contradictory
97
+ * labels, and `make parity` compared only characters, so it stayed green through
98
+ * the whole of 0.3.8.
99
+ *
100
+ * The order reproduces what Python already did rather than inventing a new one,
101
+ * because every accuracy figure this engine has published was measured with
102
+ * Python resolving these ties by its registration order. Ordering on the entity
103
+ * type instead was tried first and re-tuned the corpus twice over: it turned the
104
+ * German `Steuerß12345678911` from `TAX_ID`/DE into `NATIONAL_ID`/EL, and moved
105
+ * 820 further spans from `DOB` to `DATE_OF_DEATH`. So the key is deliberately
106
+ * not total — candidates from the same country are left to the stable sort,
107
+ * which preserves the order that country's pattern list was written in, and both
108
+ * SDKs write theirs in the same order.
109
+ *
110
+ * Taken from the **pattern**, never the finished detection: this SDK re-types a
111
+ * cued candidate before the sort and Python re-types after it, so keying on the
112
+ * final type would reintroduce the divergence in a subtler form.
113
+ *
114
+ * Kept character-for-character in step with `tie_key` in `rules/engine.py`.
115
+ */
116
+ function tieKey(countryCode) {
117
+ // "SHARED" and "CUSTOM" are not countries and sort as themselves under a
118
+ // plain alphabetical key — landing between PT and SK, rather than ahead of
119
+ // every country the way the registry loads them. The bucket restores that.
120
+ const bucket = countryCode === "SHARED" ? "0" : countryCode === "CUSTOM" ? "1" : "2";
121
+ // NUL separator, written as an escape so this file stays plain ASCII, and
122
+ // matching Python byte for byte. It must sort below every character a code
123
+ // or type name can contain, or a code that is a prefix of another would
124
+ // order differently in the two SDKs.
125
+ return `${bucket}\u0000${countryCode}`;
126
+ }
84
127
  const warnedCountries = new Set();
85
128
  function warnUnknownCountry(code) {
86
129
  if (warnedCountries.has(code))
@@ -240,7 +283,7 @@ class RuleEngine {
240
283
  else if (m.patternDef.validator !== null && !m.patternDef.requiresContext) {
241
284
  failedSpans.push({ start: m.start, end: m.end, code: m.countryCode,
242
285
  etype: String(m.patternDef.entityType) });
243
- if (cues_js_1.CUE_TARGETS.has(String(m.patternDef.entityType))
286
+ if (cues_js_1.RESCUE_TARGETS.has(String(m.patternDef.entityType))
244
287
  && (0, cues_js_1.cuedType)(text, m.start) === m.patternDef.entityType)
245
288
  rescued.push(m);
246
289
  }
@@ -452,7 +495,7 @@ class RuleEngine {
452
495
  // the value is filed as a Cypriot identity number. Dropping it also clears
453
496
  // outOfScope, which would otherwise claim the detection belongs to a
454
497
  // country the caller did not declare while naming no country at all.
455
- const retyped = (0, cues_js_1.retypedBy)(text, match.start, entityType);
498
+ const retyped = (0, cues_js_1.retypedBy)(text, match.start, entityType, countryScores.get(match.countryCode) ?? 0);
456
499
  if (retyped !== null) {
457
500
  entityType = retyped;
458
501
  country = null;
@@ -466,6 +509,7 @@ class RuleEngine {
466
509
  length: match.end - match.start,
467
510
  countryScore: countryScores.get(match.countryCode) ?? 0,
468
511
  inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
512
+ tie: tieKey(match.countryCode),
469
513
  tier: 0,
470
514
  det: {
471
515
  entityType,
@@ -504,6 +548,7 @@ class RuleEngine {
504
548
  length: match.end - match.start,
505
549
  countryScore: countryScores.get(match.countryCode) ?? 0,
506
550
  inScope: declared === null || declared.has(match.countryCode) ? 1 : 0,
551
+ tie: tieKey(match.countryCode),
507
552
  tier: 0,
508
553
  det: {
509
554
  entityType: match.patternDef.entityType,
@@ -519,7 +564,8 @@ class RuleEngine {
519
564
  }
520
565
  for (const d of (0, structural_js_1.detectStructuralDob)(text)) {
521
566
  candidates.push({ blindPriority: 1, cue: 0, priority: 1, length: d.end - d.start,
522
- countryScore: 0, inScope: 1, tier: 0, det: d });
567
+ countryScore: 0, inScope: 1, tier: 0, det: d,
568
+ tie: tieKey(d.country ?? "") });
523
569
  }
524
570
  const detections = this.deduplicate(candidates);
525
571
  detections.sort((a, b) => a.start - b.start || b.end - a.end);
@@ -587,7 +633,8 @@ class RuleEngine {
587
633
  b.cue - a.cue || // local cue } country-aware
588
634
  b.priority - a.priority || // country priority } — decides the
589
635
  b.countryScore - a.countryScore || // country evidence } LABEL only
590
- b.inScope - a.inScope); // declared scope }
636
+ b.inScope - a.inScope || // declared scope }
637
+ (a.tie < b.tie ? -1 : a.tie > b.tie ? 1 : 0)); // total } — see tieKey
591
638
  const result = [];
592
639
  const occupied = new Set();
593
640
  for (const { det } of sorted) {
@@ -347,6 +347,53 @@ function suppressYearAsPostal(text, match) {
347
347
  // Suppress: recent years without postal context are almost never postal codes
348
348
  return true;
349
349
  }
350
+ // ── Postal code: disqualified by the word in front of it ───────────────
351
+ //
352
+ // The mirror image of `rules/cues.ts`. There a label promotes a type; here a
353
+ // word refuses one. Both were reported together: a bare four-digit run becomes
354
+ // a POSTAL_CODE as soon as *any* real postal code establishes the country,
355
+ // which is to say in almost every real document, because POSTAL_CONTEXT_NEAR
356
+ // above then rescues it from suppressYearAsPostal. Measured on the training
357
+ // corpus: 55 bare years in prose ("Opgericht in 2016", "Fondée en 2017") plus
358
+ // telephone extensions ("(toest. 3841)", "(poste interne 3318)", "(ext. 2219)").
359
+ /**
360
+ * Prepositions that can only be temporal. Nothing is ever located "since 2018",
361
+ * so these disqualify a postal code on their own.
362
+ */
363
+ const YEAR_WORD_BEFORE = /(?<![A-Za-z0-9_])(?:sinds|since|depuis|seit|siden|sedan|desde|vuonna|anno|dal)\s+$/i;
364
+ /**
365
+ * "in", "en" and "im" are *both* temporal and locative, and in exactly the
366
+ * countries whose postal codes are year-shaped. Belgian 2000 is Antwerp and
367
+ * 2018 is one of its districts, so "Rustige ligging in 2018, vlakbij openbaar
368
+ * vervoer" is an address, not a date — an earlier version of this rule
369
+ * suppressed it and lost a real postal code, which is the worse error for a
370
+ * redaction tool. So an ambiguous preposition needs a founding or payment
371
+ * participle in front of it, which is what every case in the report had:
372
+ * "Opgericht in 2016", "Fondée en 2017", "versé en 2025".
373
+ */
374
+ const YEAR_VERB_BEFORE = /(?<![A-Za-z0-9_])(?:opgericht|gesticht|opgestart|founded|established|created|fond[ée]e?|cr[ée][ée]e?|gegründet|gegruendet|errichtet|grundlagt|grundad|perustettu|fundada|fundado|fondata|costituita|vers[ée]|betaald|uitbetaald|ausgezahlt|paid|geboren|born)\s+(?:in|en|im|op|the)\s+$/i;
375
+ /**
376
+ * A capitalised word immediately after keeps the candidate: "in 2000 Antwerpen"
377
+ * really is a postal code, and so is the "AZ" of a Dutch "1105 AZ". Without
378
+ * this the rule would suppress the very addresses it is meant to leave alone.
379
+ */
380
+ const TOWN_AFTER = /^\s*[A-ZÀ-ÞĀ-Ž]/;
381
+ /**
382
+ * A telephone extension marker. "poste interne 3318" puts one qualifier word
383
+ * between the marker and the number, so allow exactly one.
384
+ */
385
+ const EXTENSION_BEFORE = /(?<![A-Za-z0-9_])(?:toest(?:el)?|ext|extension|poste|durchwahl|doorkiesnummer|tst|nebenstelle)\.?\s*(?:[a-zà-ÿ]{2,10}\s*)?[:.]?\s*$/i;
386
+ /** Suppress a postal code the word in front of it rules out. */
387
+ function suppressPostalAfterDisqualifyingWord(text, match) {
388
+ if (match.patternDef.entityType !== types_js_1.EntityType.POSTAL_CODE)
389
+ return false;
390
+ const before = text.slice(Math.max(0, match.start - 24), match.start);
391
+ if (EXTENSION_BEFORE.test(before))
392
+ return true;
393
+ return RECENT_YEAR.test(match.text.trim())
394
+ && (YEAR_WORD_BEFORE.test(before) || YEAR_VERB_BEFORE.test(before))
395
+ && !TOWN_AFTER.test(text.slice(match.end, match.end + 3));
396
+ }
350
397
  function suppressPhoneServiceNumber(_text, match) {
351
398
  if (match.patternDef.entityType !== types_js_1.EntityType.PHONE)
352
399
  return false;
@@ -796,7 +843,7 @@ const TYPE_SUPPRESSORS = {
796
843
  [types_js_1.EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
797
844
  [types_js_1.EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
798
845
  [types_js_1.EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressTaxidAsIpAddress],
799
- [types_js_1.EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalInsideIban, suppressPostalAsHouseNumber, suppressPostalInLongerIdentifier, suppressReference],
846
+ [types_js_1.EntityType.POSTAL_CODE]: [suppressCurrency, suppressUnits, suppressMath, suppressLegal, suppressYearAsPostal, suppressPostalAfterDisqualifyingWord, suppressPostalInsideIban, suppressPostalAsHouseNumber, suppressPostalInLongerIdentifier, suppressReference],
800
847
  [types_js_1.EntityType.BIC]: [suppressBicWithoutEvidence],
801
848
  [types_js_1.EntityType.BANK_ACCOUNT]: [suppressReference],
802
849
  [types_js_1.EntityType.LICENSE_PLATE]: [suppressPlateInCompound, suppressDePlateUnknownDistrict, suppressPlateAsCurrencyAmount],
package/dist/cjs/sdk.js CHANGED
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.EuRedact = void 0;
4
37
  const cache_js_1 = require("./cache.js");
@@ -48,6 +81,40 @@ class ReferentialMapper {
48
81
  }
49
82
  }
50
83
  const DEFAULT_MAX_INPUT_LENGTH = 10_485_760; // ~10 MB of text
84
+ /**
85
+ * Send a document to the cloud tier.
86
+ *
87
+ * Options the service cannot honour throw rather than being ignored. Silently
88
+ * dropping one would mean returning a result that does not match what was
89
+ * asked for — which, for anything that changes which spans come back, is
90
+ * under-redaction wearing a plausible face.
91
+ */
92
+ async function redactViaCloud(text, options) {
93
+ const { CloudClient } = await Promise.resolve().then(() => __importStar(require("./cloud/client.js")));
94
+ const countries = options.countries ?? null;
95
+ if (!countries || countries.length !== 1) {
96
+ throw new Error('cloud mode needs exactly one country, e.g. { countries: ["BE"] }. The ' +
97
+ "model is trained and evaluated per country, so a multi-country request " +
98
+ "has no defined behaviour.");
99
+ }
100
+ if (options.countryHint) {
101
+ throw new Error("countryHint is not supported in cloud mode");
102
+ }
103
+ if (options.context || options.chunkOffset) {
104
+ throw new Error("context/chunkOffset are not supported in cloud mode: the model has never " +
105
+ "seen a chunk boundary, so the service rejects oversized input rather " +
106
+ "than splitting it");
107
+ }
108
+ if (options.referentialIntegrity) {
109
+ throw new Error("referentialIntegrity is not supported in cloud mode");
110
+ }
111
+ // detectDates is deliberately NOT forwarded. The service always runs its
112
+ // rules engine with dates on, because that is what the model was trained
113
+ // against; the caller's value cannot change that. It is the one ignored
114
+ // option that is safe to ignore — it can only cause MORE to be detected,
115
+ // never less, so it cannot produce under-redaction.
116
+ return new CloudClient().redact(text, { country: countries[0] });
117
+ }
51
118
  /**
52
119
  * Reject a bare string where a list of country codes is expected.
53
120
  *
@@ -95,6 +162,19 @@ class EuRedact {
95
162
  redact(text, options = {}) {
96
163
  checkCountryArg(options.countries, "countries");
97
164
  checkCountryArg(options.countryHint, "countryHint");
165
+ const requestedMode = options.mode ?? "rules";
166
+ if (requestedMode === "cloud") {
167
+ // Cannot be served synchronously: the cloud tier is a network call, and
168
+ // returning rules-only output instead would be the silent under-redaction
169
+ // this guard exists to prevent. Point at the async entry point rather
170
+ // than inventing a Promise-or-value union return type.
171
+ throw new Error('mode: "cloud" is asynchronous — use redactAsync(text, { mode: "cloud" }) ' +
172
+ "(or EuRedact#redactAsync). redact() is synchronous and can only serve " +
173
+ 'mode: "rules".');
174
+ }
175
+ if (requestedMode !== "rules") {
176
+ throw new Error(`unknown mode ${JSON.stringify(requestedMode)}: expected "rules" or "cloud"`);
177
+ }
98
178
  if (text.length > this.maxInputLength) {
99
179
  throw new Error(`Input text length (${text.length.toLocaleString()} chars) exceeds the maximum ` +
100
180
  `(${this.maxInputLength.toLocaleString()} chars). Split the input or ` +
@@ -178,6 +258,18 @@ class EuRedact {
178
258
  }
179
259
  return result;
180
260
  }
261
+ /**
262
+ * Redact, awaiting the cloud tier when `mode: "cloud"` is asked for.
263
+ *
264
+ * `mode: "rules"` resolves immediately with exactly what `redact()` returns,
265
+ * so callers that may or may not use the cloud tier can hold one code path.
266
+ */
267
+ async redactAsync(text, options = {}) {
268
+ const mode = options.mode ?? "rules";
269
+ if (mode !== "cloud")
270
+ return this.redact(text, options);
271
+ return redactViaCloud(text, options);
272
+ }
181
273
  redactBatch(texts, options = {}) {
182
274
  // Generation is country-blind, so every country's patterns are loaded
183
275
  // regardless of what the caller declared.
package/dist/cjs/types.js CHANGED
@@ -1,9 +1,25 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DetectionSource = exports.LEGACY_TYPE_ALIASES = exports.EntityType = void 0;
4
+ exports.canonicalType = canonicalType;
4
5
  var EntityType;
5
6
  (function (EntityType) {
6
- EntityType["NAME"] = "NAME";
7
+ /**
8
+ * Canonical name for a person's name. `NAME` is kept below as a legacy alias
9
+ * with the same value, exactly as `IBAN` aliases `BANK_ACCOUNT`, so
10
+ * `EntityType.NAME === EntityType.PERSON_NAME` and existing code keeps
11
+ * working — but the emitted value, and therefore the `[PERSON_NAME]`
12
+ * placeholder written into redacted text, is canonical.
13
+ *
14
+ * Renamed rather than translated at the cloud boundary: two names for one
15
+ * type is how a whole category goes missing when someone filters on the
16
+ * spelling they happened to know. Nothing could have depended on the old
17
+ * value — the type is cloud-only and the cloud tier was stubbed, so it was
18
+ * never emitted.
19
+ */
20
+ EntityType["PERSON_NAME"] = "PERSON_NAME";
21
+ /** @deprecated legacy alias of {@link EntityType.PERSON_NAME} */
22
+ EntityType["NAME"] = "PERSON_NAME";
7
23
  EntityType["ADDRESS"] = "ADDRESS";
8
24
  /**
9
25
  * Canonical name for a bank account identifier. `IBAN` is kept below as a
@@ -51,6 +67,20 @@ var EntityType;
51
67
  EntityType["UUID"] = "UUID";
52
68
  EntityType["SOCIAL_HANDLE"] = "SOCIAL_HANDLE";
53
69
  EntityType["SECRET"] = "SECRET";
70
+ // ── [CLOUD EXTENSION] ────────────────────────────────────────────────────
71
+ // Types only the cloud tier can detect. The rule engine never emits these —
72
+ // there is no shape to match on — which is exactly why they exist: the
73
+ // model's job is what the rules cannot catch. Named to match the detection
74
+ // canon the service is trained and evaluated against.
75
+ EntityType["ORGANISATION_NAME"] = "ORGANISATION_NAME";
76
+ EntityType["JOB_TITLE"] = "JOB_TITLE";
77
+ EntityType["MEDICAL_CONDITION"] = "MEDICAL_CONDITION";
78
+ EntityType["SENSITIVE_ATTRIBUTE"] = "SENSITIVE_ATTRIBUTE";
79
+ EntityType["BIOMETRIC_REF"] = "BIOMETRIC_REF";
80
+ EntityType["FINANCIAL_AMOUNT"] = "FINANCIAL_AMOUNT";
81
+ EntityType["QUASI_IDENTIFIER"] = "QUASI_IDENTIFIER";
82
+ EntityType["CREDENTIAL"] = "CREDENTIAL";
83
+ EntityType["URL"] = "URL";
54
84
  EntityType["OTHER"] = "OTHER";
55
85
  })(EntityType || (exports.EntityType = EntityType = {}));
56
86
  /**
@@ -60,7 +90,21 @@ var EntityType;
60
90
  */
61
91
  exports.LEGACY_TYPE_ALIASES = {
62
92
  IBAN: "BANK_ACCOUNT",
93
+ NAME: "PERSON_NAME",
94
+ STREET_ADDRESS: "ADDRESS",
95
+ NATIONALITY_ETHNICITY: "SENSITIVE_ATTRIBUTE",
63
96
  };
97
+ /**
98
+ * Resolve a possibly-legacy type name to its canonical form.
99
+ *
100
+ * Mirrors `EntityType._missing_` in the Python SDK. An unrecognised name is
101
+ * returned unchanged rather than dropped: a client one release behind the
102
+ * service must not silently lose a whole category of PII.
103
+ */
104
+ function canonicalType(name) {
105
+ const upper = String(name ?? "").trim().toUpperCase();
106
+ return exports.LEGACY_TYPE_ALIASES[upper] ?? upper;
107
+ }
64
108
  var DetectionSource;
65
109
  (function (DetectionSource) {
66
110
  DetectionSource["RULES"] = "rules";
@@ -0,0 +1,42 @@
1
+ /**
2
+ * [CLOUD EXTENSION] The cloud client.
3
+ *
4
+ * Speaks to the euRedact inference service: a deterministic rules engine
5
+ * followed by a fine-tuned model asked only *what did the rules miss?* The
6
+ * service returns both the redacted document and located spans, so this
7
+ * module's job is transport and translation, not detection.
8
+ *
9
+ * Three things it hides from the caller:
10
+ *
11
+ * - **Whether the document finished inside the service's sync window.** A short
12
+ * document comes back on the original request; a long one, or one arriving
13
+ * while a GPU node is cold, returns a job handle and is polled. Callers
14
+ * should never write that branch, so `redact()` resolves either way.
15
+ * - **Retries.** Every request carries an `Idempotency-Key`, so a retry after a
16
+ * timeout cannot produce a second job or a second usage row.
17
+ * - **`Retry-After`.** When the service says how long to wait, that is obeyed
18
+ * rather than second-guessed with a backoff curve.
19
+ *
20
+ * Zero dependencies, like the rest of the package: this uses the platform's
21
+ * own `fetch`.
22
+ */
23
+ import { type CloudConfig } from "./config.js";
24
+ import { type RedactResult } from "../types.js";
25
+ export interface CloudRedactOptions {
26
+ country: string;
27
+ language?: string;
28
+ priority?: "interactive" | "batch";
29
+ rulesOnly?: boolean;
30
+ idempotencyKey?: string;
31
+ /** Injectable for tests. Defaults to the platform `fetch`. */
32
+ fetchImpl?: typeof fetch;
33
+ }
34
+ export declare class CloudClient {
35
+ readonly config: CloudConfig;
36
+ constructor(config?: CloudConfig | null);
37
+ private headers;
38
+ private raiseFor;
39
+ redact(text: string, options: CloudRedactOptions): Promise<RedactResult>;
40
+ private poll;
41
+ private withTimeout;
42
+ }