euredact 0.5.0 → 0.6.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.
@@ -313,9 +313,85 @@ function suppressSequential(_text, match) {
313
313
  const clean = match.text.replace(/[\s.\-]/g, "");
314
314
  return SEQUENTIAL_PATTERNS.test(clean);
315
315
  }
316
+ // ── The engine's own output ───────────────────────────────────────────────
317
+ //
318
+ // Redaction has to be idempotent: running it over a document it already
319
+ // produced must leave the placeholders it wrote alone. It did not.
320
+ // `[POSTAL_CODE]` is thirteen characters of mixed case with an underscore, so
321
+ // the entropy-based SECRET rule read it as a credential and a second pass
322
+ // replaced it with `[SECRET]` (rules-engine#33).
323
+ //
324
+ // All three emitted forms are guarded, because all three come back as input in
325
+ // a re-processing pipeline: `[TYPE]`, `TYPE_1` (referentialIntegrity) and
326
+ // `TYPE_K7Q2` (tokenize -- where a false detection breaks restore()).
327
+ //
328
+ // Only real entity-type names count. Guarding any bracketed upper-case token
329
+ // would be simpler and wrong: `[AKIAIOSFODNN7EXAMPLE]` is one, and also a live
330
+ // AWS key. A placeholder for a *custom* pattern is therefore not covered.
331
+ // The closing bracket is optional: another pattern's span need not agree with
332
+ // this one about where the marker ends. The assigned-secret rule stops before
333
+ // sentence punctuation (rules-engine#35), so it claims "[POSTAL_CODE" without
334
+ // the "]". The opening bracket is still required.
335
+ const PLACEHOLDER = /^(?:\[([A-Z][A-Z0-9_]*)\]?|([A-Z][A-Z0-9_]*?)_(?:\d+|[A-HJ-NP-Z2-9]{4}))$/;
336
+ const KNOWN_TYPE_NAMES = new Set(Object.values(types_js_1.EntityType));
337
+ function suppressRedactionPlaceholder(_text, match) {
338
+ const found = PLACEHOLDER.exec(match.text);
339
+ if (found === null)
340
+ return false;
341
+ const name = found[1] ?? found[2];
342
+ return KNOWN_TYPE_NAMES.has(name);
343
+ }
316
344
  const RECENT_YEAR = /^(?:19[5-9]\d|20[0-3]\d)$/;
317
345
  const POSTAL_CONTEXT_NEAR = /(?:postcode|postal|code\s*postal|PLZ|Postleitzahl|postnummer|postinumero|póstnúmer|zip|straat|straße|strasse|rue\s|via\s|calle\s|rua\s|ulica|utca|street|avenue|laan\s|weg\s|plein|adres|adresse|address|woonplaats|wonende|woonachtig|gevestigd|domicili|demeurant|résidant|residant|bosatt|bopæl|wohnhaft|ansässig|stad\b|ville\b|city\b|Stadt|città|ciudad|cidade|miasto|město|város)/i;
318
346
  const DATE_KEYWORD_NEAR = /(?:født|fødselsdato|fødsel|Fødselsdato|född|födelsedatum|födelsedag|syntynyt|syntymäaika|fæddur|fæðingardagur|geboren|geboortedatum|Geburtsdatum|nascido|nacido|data di nascita|nato il|nata il|Tiltr[æa]delsesdato|Tiltredelsesdato|datum|date\b|Datum|jaar|year|année|Jahr|since|sinds|depuis|seit|\d{2}\.\d{2}\.|(?:januar|februar|marts|april|maj|juni|juli|august|september|oktober|november|december|januari|februari|mars|mei|juin|juillet|août))/i;
347
+ // A sentence boundary: a full stop, question or exclamation mark followed by
348
+ // space, or a line break. Written so it cannot fire inside "11.03.1970", where
349
+ // the periods are not followed by whitespace -- that date is one of the false
350
+ // positives this gate has to reject.
351
+ const SENTENCE_BREAK = /[.!?][ \t\n]|\n/;
352
+ const SENTENCE_BREAK_G = /[.!?][ \t\n]|\n/g;
353
+ // The value's own sentence, bounded either side.
354
+ function sentenceAround(text, start, end, reach = 160) {
355
+ let before = text.slice(Math.max(0, start - reach), start);
356
+ let after = text.slice(end, end + reach);
357
+ let lastEnd = -1;
358
+ SENTENCE_BREAK_G.lastIndex = 0;
359
+ for (let m = SENTENCE_BREAK_G.exec(before); m !== null; m = SENTENCE_BREAK_G.exec(before)) {
360
+ lastEnd = m.index + m[0].length;
361
+ }
362
+ if (lastEnd >= 0)
363
+ before = before.slice(lastEnd);
364
+ const found = SENTENCE_BREAK.exec(after);
365
+ if (found !== null)
366
+ after = after.slice(0, found.index);
367
+ return before + after;
368
+ }
369
+ // A postal label whose only distance from the value is punctuation:
370
+ // "PLZ: 2011", "code postal : 2011", "Woonplaats: 1950".
371
+ const POSTAL_LABEL_TOUCHING = new RegExp(`(?:${POSTAL_CONTEXT_NEAR.source})[\\s:.\\-]{0,4}$`, "i");
372
+ // A capitalised place name after the code: "2000 Antwerpen". A following label
373
+ // ("1970 Fødselsnummer:") is excluded by the colon.
374
+ const CITY_AFTER = /^[ \t](?!\w+\s*:)[A-ZÀ-ÞŁŠŽ][\w\-']{2,}/u;
375
+ // Every position is local to the value or inside its own sentence. Proximity to
376
+ // an address *word* anywhere in a 300-character window is what the old rescue
377
+ // asked, and that is a neighbourhood rather than a position (rules-engine#32).
378
+ function yearSitsInAddressStructure(text, start, end) {
379
+ const before = text.slice(Math.max(0, start - 28), start);
380
+ // "Amsterdam, 2026" -- the comma of an address line.
381
+ if (/,[ \t]*$/.test(before))
382
+ return true;
383
+ // "PLZ: 2011" -- a postal label with nothing but punctuation between.
384
+ if (POSTAL_LABEL_TOUCHING.test(before))
385
+ return true;
386
+ // A capitalised place name immediately after.
387
+ if (CITY_AFTER.test(text.slice(end, end + 24)))
388
+ return true;
389
+ // An address word in the value's own sentence. "Te huur: Lange Nieuwstraat
390
+ // 12, rustige ligging in 2018, vlakbij openbaar vervoer." is an Antwerp
391
+ // postal code in prose; the false positives put the year and the address in
392
+ // different sentences.
393
+ return POSTAL_CONTEXT_NEAR.test(sentenceAround(text, start, end));
394
+ }
319
395
  function suppressYearAsPostal(text, match) {
320
396
  if (match.patternDef.entityType !== types_js_1.EntityType.POSTAL_CODE)
321
397
  return false;
@@ -334,8 +410,10 @@ function suppressYearAsPostal(text, match) {
334
410
  return true;
335
411
  const [before, after] = getContext(text, match.start, match.end);
336
412
  const context = before + after;
337
- // Keep as postal code if postal/address context nearby
338
- if (POSTAL_CONTEXT_NEAR.test(context))
413
+ // Keep as postal code only when the value sits in address *structure* --
414
+ // see yearSitsInAddressStructure for why a 300-character window of address
415
+ // words was the defect rather than the keyword list.
416
+ if (yearSitsInAddressStructure(text, match.start, match.end))
339
417
  return false;
340
418
  // Keep if preceded by comma+space (address pattern: "Amsterdam, 2026")
341
419
  const immediateBefore = text.slice(Math.max(0, match.start - 3), match.start);
@@ -510,6 +588,35 @@ function suppressPostalInsideIban(text, match) {
510
588
  }
511
589
  return false;
512
590
  }
591
+ // An IBAN-shaped run: two letters, two check digits, then grouped
592
+ // alphanumerics. Deliberately not checksum-gated -- the point is to describe the
593
+ // run a human wrote as one account number, including the ones that fail their
594
+ // checksum, because those are exactly the ones no structured detector claims.
595
+ const ACCOUNT_RUN = /\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]{2,4}){2,8}\b/g;
596
+ // "Compte: FR76 3000 4008 0300 0109 5374" fails the IBAN checksum, so no
597
+ // BANK_ACCOUNT candidate survives and the Danish eight-digit phone pattern took
598
+ // "3000 4008" out of the middle of it, leaving the rest in the clear
599
+ // (rules-engine#30). POSTAL_CODE has carried its own version of this guard
600
+ // since 0.3.3, in suppressPostalInsideIban.
601
+ function suppressPhoneInsideAccountRun(text, match) {
602
+ if (match.patternDef.entityType !== types_js_1.EntityType.PHONE)
603
+ return false;
604
+ const lineStart = text.lastIndexOf("\n", match.start) + 1;
605
+ let lineEnd = text.indexOf("\n", match.end);
606
+ if (lineEnd < 0)
607
+ lineEnd = text.length;
608
+ const line = text.slice(lineStart, lineEnd);
609
+ ACCOUNT_RUN.lastIndex = 0;
610
+ for (let m = ACCOUNT_RUN.exec(line); m !== null; m = ACCOUNT_RUN.exec(line)) {
611
+ const runStart = lineStart + m.index;
612
+ const runEnd = runStart + m[0].length;
613
+ if (runStart <= match.start && match.end <= runEnd
614
+ && runEnd - runStart > match.end - match.start) {
615
+ return true;
616
+ }
617
+ }
618
+ return false;
619
+ }
513
620
  function suppressPostalAsHouseNumber(text, match) {
514
621
  if (match.patternDef.entityType !== types_js_1.EntityType.POSTAL_CODE)
515
622
  return false;
@@ -738,12 +845,19 @@ function suppressBicWithoutEvidence(text, match, scratch) {
738
845
  // Repeated per alternative, the engine retried the same unbounded prefix five
739
846
  // times per starting position — the most expensive single regex in the engine
740
847
  // at 197 ms per 1 MB document. Same language, 8x faster on the worst case.
848
+ // An identifier cue that is really a postal label -- see the use below.
849
+ const POSTAL_LABEL_CUE = /^post/i;
741
850
  const ID_CUE_BEFORE = /(?:[\w\-]*(?:Nr|N[°ºo]|Nummer|Numero|Numéro)|No|number|num|Kennzahl|Aktenzeichen|Az|e-?card|Polizze|Police|Policen)\.?\s*:?\s*$/i;
742
851
  // An international dialling prefix earlier on the same line, with nothing but
743
852
  // number punctuation in between: these digits belong to the phone detector.
744
853
  const DIALLING_PREFIX_BEFORE = /\+\d{1,3}[\d\s\-().]*$/;
745
854
  // A country prefix on a postal code — "A-1010 Wien", "B-2000", "L-1234".
746
- const COUNTRY_PREFIXED = /(?:^|[^A-Za-z0-9])[A-Z]{1,2}$/;
855
+ // The boundary excludes a hyphen, which is what makes this a *prefix* rather
856
+ // than a segment. It did not, so any `XX-NNNNN` tail of a hyphenated reference
857
+ // read as a country-prefixed address: in "PV-2026-LU-09143" the `LU` is
858
+ // preceded by `-` and the German five-digit postal pattern kept `09143`
859
+ // (rules-engine#31).
860
+ const COUNTRY_PREFIXED = /(?:^|[^A-Za-z0-9\-])[A-Z]{1,2}$/;
747
861
  /**
748
862
  * Suppress bare digit runs that belong to a longer number, not an address.
749
863
  *
@@ -787,7 +901,16 @@ function suppressPostalInLongerIdentifier(text, match) {
787
901
  if (/^[ \t]+\d/.test(text.slice(match.end, match.end + 4)))
788
902
  return true;
789
903
  // An identifier label introduces the digits: "DiNr. 4471", "Policen-Nr."
790
- if (ID_CUE_BEFORE.test(text.slice(Math.max(0, match.start - 40), match.start)))
904
+ // Unless the label is a *postal* one. ID_CUE_BEFORE matches any word ending
905
+ // in "Nummer", "Nr", "Numero" or "Numéro" -- the wildcard is `[\w\-]*` -- so
906
+ // it matches `postnummer`, `postnr` and `postinumero`, the canonical postal
907
+ // labels of Norway, Denmark and Finland. Those countries write a bare
908
+ // four- or five-digit code, so the isdigit() guard above lets this run and
909
+ // the code was suppressed by its own label: `postnummer: 5020 Bergen`
910
+ // produced nothing at all (rules-engine#41).
911
+ const before = text.slice(Math.max(0, match.start - 40), match.start);
912
+ const cue = ID_CUE_BEFORE.exec(before);
913
+ if (cue !== null && !POSTAL_LABEL_CUE.test(cue[0].trimStart()))
791
914
  return true;
792
915
  // Digits after an international dialling prefix belong to PHONE
793
916
  const [lineStart] = enclosingLine(text, match.start, match.end);
@@ -839,7 +962,7 @@ function suppressRequiresContext(text, match) {
839
962
  return !match.patternDef.contextKeywords.some(kw => context.includes(kw.toLowerCase()));
840
963
  }
841
964
  const TYPE_SUPPRESSORS = {
842
- [types_js_1.EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneServiceNumber, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
965
+ [types_js_1.EntityType.PHONE]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressPhoneServiceNumber, suppressPhoneInsideAccountRun, suppressPhoneDateOverlap, suppressPhoneAsNumberRange],
843
966
  [types_js_1.EntityType.NATIONAL_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressLegal, suppressMath, suppressNatidAsPassport, suppressSeNatidAsOrg],
844
967
  [types_js_1.EntityType.SSN]: [suppressCurrency, suppressUnits, suppressReference, suppressMath],
845
968
  [types_js_1.EntityType.TAX_ID]: [suppressCurrency, suppressUnits, suppressReference, suppressMath, suppressTaxidAsIpAddress],
@@ -853,6 +976,8 @@ const TYPE_SUPPRESSORS = {
853
976
  function shouldSuppress(text, match, scratch) {
854
977
  if (suppressSequential(text, match))
855
978
  return true;
979
+ if (suppressRedactionPlaceholder(text, match))
980
+ return true;
856
981
  const typeSups = TYPE_SUPPRESSORS[match.patternDef.entityType];
857
982
  if (typeSups) {
858
983
  for (const s of typeSups) {
@@ -513,10 +513,24 @@ function validatePortugueseNif(candidate) {
513
513
  const check = remainder < 2 ? 0 : 11 - remainder;
514
514
  return parseInt(c[8]) === check;
515
515
  }
516
+ // PESEL carries the century in the month field: +0 is the 1900s, +20 the 2000s,
517
+ // +40 the 2100s, +60 the 2200s, +80 the 1800s. Without a date check this
518
+ // accepted 80666603210 -- day 66 -- as NATIONAL_ID at high confidence
519
+ // (rules-engine#43).
516
520
  function validatePolishPesel(candidate) {
517
521
  const c = candidate.replace(/[\s\-]/g, "");
518
522
  if (c.length !== 11 || !/^\d{11}$/.test(c))
519
523
  return false;
524
+ const rawMonth = parseInt(c.slice(2, 4), 10);
525
+ let month = null;
526
+ for (const off of [0, 20, 40, 60, 80]) {
527
+ if (rawMonth - off >= 1 && rawMonth - off <= 12) {
528
+ month = rawMonth - off;
529
+ break;
530
+ }
531
+ }
532
+ if (month === null || !isRealDay(month, parseInt(c.slice(4, 6), 10)))
533
+ return false;
520
534
  const d = c.split("").map(Number);
521
535
  const weights = [1, 3, 7, 9, 1, 3, 7, 9, 1, 3];
522
536
  let total = 0;
@@ -535,18 +549,53 @@ function validatePolishNip(candidate) {
535
549
  total += d[i] * weights[i];
536
550
  return total % 11 === d[9];
537
551
  }
552
+ // Days per month for the rodné číslo date check. February is 29 because the
553
+ // century is not recoverable from a two-digit year, so a leap year cannot be
554
+ // ruled out -- being permissive by one day is the right direction here.
555
+ const RC_DAYS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
556
+ // The calendar month a rodné číslo month field encodes, or null. Four ranges
557
+ // are legal: 1-12 is a man, +50 a woman, and since 2004 +20 marks a birth on a
558
+ // day whose sequence numbers were exhausted (so +70 is a woman on such a day).
559
+ // Whether `day` exists in `month`, February permissive at 29 because a
560
+ // two-digit year does not reveal the century.
561
+ function isRealDay(month, day) {
562
+ return month >= 1 && month <= 12 && day >= 1 && day <= RC_DAYS[month - 1];
563
+ }
564
+ function czechMonth(raw) {
565
+ for (const offset of [0, 20, 50, 70]) {
566
+ if (raw - offset >= 1 && raw - offset <= 12)
567
+ return raw - offset;
568
+ }
569
+ return null;
570
+ }
571
+ // The date is checked, not only the checksum. Without it the validator accepted
572
+ // an impossible month, and a Czech mobile number is the same shape -- nine
573
+ // digits opening 6 or 7 -- so any mobile that happened to pass mod 11 was typed
574
+ // NATIONAL_ID at high confidence: 606666032 (MM=66), 778836400 (MM=88),
575
+ // 728990603 (MM=89). 284 per corpus pass, the largest single false-positive
576
+ // bucket in the evaluation (rules-engine#37).
538
577
  function validateCzechBirthNumber(candidate) {
539
578
  const c = candidate.replace(/[/\s]/g, "");
540
579
  if (!/^\d+$/.test(c) || (c.length !== 9 && c.length !== 10))
541
580
  return false;
581
+ const month = czechMonth(parseInt(c.slice(2, 4), 10));
582
+ if (month === null)
583
+ return false;
584
+ const day = parseInt(c.slice(4, 6), 10);
585
+ if (day < 1 || day > RC_DAYS[month - 1])
586
+ return false;
542
587
  if (c.length === 10)
543
588
  return parseInt(c) % 11 === 0;
544
589
  return true;
545
590
  }
591
+ // S YY MM DD JJ NNN C -- the leading S carries the century, so the month needs
592
+ // no offset convention here (rules-engine#43).
546
593
  function validateRomanianCnp(candidate) {
547
594
  const c = candidate.replace(/[\s\-]/g, "");
548
595
  if (c.length !== 13 || !/^\d{13}$/.test(c) || !"12345678".includes(c[0]))
549
596
  return false;
597
+ if (!isRealDay(parseInt(c.slice(3, 5), 10), parseInt(c.slice(5, 7), 10)))
598
+ return false;
550
599
  const key = "279146358279";
551
600
  let total = 0;
552
601
  for (let i = 0; i < 12; i++)
@@ -593,10 +642,14 @@ function validateCroatianOib(candidate) {
593
642
  const check = (11 - product) % 10;
594
643
  return check === parseInt(c[10]);
595
644
  }
645
+ // DD MM YYY RR BBB K -- note EMSO opens with the *day*, where the Czech, Polish
646
+ // and Romanian forms open with the year or a century digit (rules-engine#43).
596
647
  function validateSlovenianEmso(candidate) {
597
648
  const c = candidate.replace(/[\s\-]/g, "");
598
649
  if (c.length !== 13 || !/^\d{13}$/.test(c))
599
650
  return false;
651
+ if (!isRealDay(parseInt(c.slice(2, 4), 10), parseInt(c.slice(0, 2), 10)))
652
+ return false;
600
653
  const d = c.split("").map(Number);
601
654
  const weights = [7, 6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
602
655
  let total = 0;
package/dist/cjs/sdk.js CHANGED
@@ -86,6 +86,96 @@ function applyReplacements(text, detections, labelFor) {
86
86
  parts.push(text.slice(pos));
87
87
  return parts.join("");
88
88
  }
89
+ /**
90
+ * Bracket-mask `text` and record where each label landed.
91
+ *
92
+ * Returns what is sent to the cloud tier, plus one `WireLabel` per label.
93
+ * Everything between labels is copied unchanged, so the labels are all it
94
+ * takes to carry an offset from the masked text back to the original.
95
+ *
96
+ * Always `[TYPE]`, whatever label scheme the caller asked for. That is the
97
+ * syntax the model was trained on, where a placeholder means "already handled,
98
+ * do not list it". A token or a numbered label would be read as ordinary text,
99
+ * so the format on the wire is a model contract and not an output option.
100
+ */
101
+ function maskForCloud(text, detections) {
102
+ const parts = [];
103
+ const labels = [];
104
+ let pos = 0;
105
+ let out = 0;
106
+ for (const det of detections) {
107
+ const start = Math.max(det.start, pos);
108
+ if (det.end <= start)
109
+ continue;
110
+ const label = `[${det.entityType}]`;
111
+ out += start - pos;
112
+ parts.push(text.slice(pos, start), label);
113
+ labels.push([out, out + label.length, start, det.end]);
114
+ out += label.length;
115
+ pos = det.end;
116
+ }
117
+ parts.push(text.slice(pos));
118
+ return [parts.join(""), labels];
119
+ }
120
+ /** Index of the last label starting at or before `offset` — strictly before
121
+ * when `strict` — or -1. */
122
+ function labelBefore(labels, offset, strict) {
123
+ let lo = 0;
124
+ let hi = labels.length;
125
+ while (lo < hi) {
126
+ const mid = (lo + hi) >>> 1;
127
+ if (strict ? labels[mid][0] < offset : labels[mid][0] <= offset)
128
+ lo = mid + 1;
129
+ else
130
+ hi = mid;
131
+ }
132
+ return lo - 1;
133
+ }
134
+ /**
135
+ * Carry the service's spans from the masked text onto the original.
136
+ *
137
+ * The service only ever saw `masked`, so its offsets index that. An offset in
138
+ * copied text shifts by whatever the labels before it added or removed. An
139
+ * offset inside a label has no counterpart in the original, so it snaps
140
+ * outward to the edge of the value the label replaced: an address the model
141
+ * reports around `[POSTAL_CODE]` covers the postal code, never half of it.
142
+ * Over-masking is the safe direction.
143
+ *
144
+ * A span lying wholly inside one label describes the label, not the document.
145
+ * It is dropped; the local detection behind the label stands.
146
+ *
147
+ * Throws `Unplaceable` when a span does not match the text that was sent. Such
148
+ * a span cannot be placed, and masking where it points would cover the wrong
149
+ * characters and leave the right ones in the clear.
150
+ */
151
+ function ontoOriginal(spans, masked, text, labels, Unplaceable) {
152
+ const placed = [];
153
+ for (const span of spans) {
154
+ if (!(0 <= span.start && span.start <= span.end && span.end <= masked.length) ||
155
+ masked.slice(span.start, span.end) !== span.text) {
156
+ throw new Unplaceable("span offsets do not match the text that was sent; cannot place the " +
157
+ "service's detections in the document");
158
+ }
159
+ if (span.start === span.end)
160
+ continue;
161
+ let start = span.start;
162
+ let at = labelBefore(labels, span.start, false);
163
+ if (at >= 0) {
164
+ const [, maskedEnd, origStart, origEnd] = labels[at];
165
+ if (span.end <= maskedEnd)
166
+ continue;
167
+ start = span.start < maskedEnd ? origStart : origEnd + span.start - maskedEnd;
168
+ }
169
+ let end = span.end;
170
+ at = labelBefore(labels, span.end, true);
171
+ if (at >= 0) {
172
+ const [, maskedEnd, , origEnd] = labels[at];
173
+ end = origEnd + Math.max(0, span.end - maskedEnd);
174
+ }
175
+ placed.push({ ...span, start, end, text: text.slice(start, end) });
176
+ }
177
+ return placed;
178
+ }
89
179
  /**
90
180
  * Characters a token suffix is drawn from. No vowels, so a suffix never spells
91
181
  * a word; no 0/1/I/O, so it survives being read back by a person; no
@@ -425,7 +515,16 @@ class EuRedact {
425
515
  return (det) => `[${det.entityType}]`;
426
516
  }
427
517
  /**
428
- * Send a document to the cloud tier.
518
+ * Run the rules here, then send what they left to the cloud tier.
519
+ *
520
+ * Local-first: the rules engine masks everything it can find on the caller's
521
+ * machine, and only that masked text is sent. The service looks for what has
522
+ * no shape to match on — names, employers, diagnoses — and answers with
523
+ * spans relative to the text it received, which are mapped back onto the
524
+ * original here.
525
+ *
526
+ * This is minimisation, not an exemption: what the rules miss, and the names
527
+ * and diagnoses the model is there to find, still travel.
429
528
  *
430
529
  * Options the service cannot honour throw rather than being ignored. Silently
431
530
  * dropping one would mean returning a result that does not match what was
@@ -434,6 +533,7 @@ class EuRedact {
434
533
  */
435
534
  async redactViaCloud(text, options) {
436
535
  const { CloudClient } = await Promise.resolve().then(() => __importStar(require("./cloud/client.js")));
536
+ const { CloudError } = await Promise.resolve().then(() => __importStar(require("./cloud/errors.js")));
437
537
  const countries = options.countries ?? null;
438
538
  if (!countries || countries.length !== 1) {
439
539
  throw new Error('cloud mode needs exactly one country, e.g. { countries: ["BE"] }. The ' +
@@ -451,43 +551,34 @@ class EuRedact {
451
551
  if (options.referentialIntegrity) {
452
552
  throw new Error("referentialIntegrity is not supported in cloud mode");
453
553
  }
454
- // detectDates is deliberately NOT forwarded. The service always runs its
455
- // rules engine with dates on, because that is what the model was trained
456
- // against; the caller's value cannot change that. It is the one ignored
457
- // option that is safe to ignore — it can only cause MORE to be detected,
458
- // never less, so it cannot produce under-redaction.
459
554
  const allowed = this.allowedFor(options);
460
555
  const domains = this.domainsFor(options);
461
- const result = await new CloudClient().redact(text, { country: countries[0] });
462
- if (options.tokenize || allowed.size > 0 || domains.size > 0) {
463
- await this.remaskCloudResult(result, text, { tokenize: options.tokenize ?? false, allowed, domains });
464
- }
465
- return result;
466
- }
467
- /**
468
- * Rebuild the masked text from the service's spans, in place.
469
- *
470
- * The service masks with bracketed labels only. Any other output option is
471
- * applied here, from its spans: the service builds its `redacted_text` from
472
- * exactly the spans it returns (entities it reports but cannot place are
473
- * listed separately and never applied), so nothing it masked is lost by
474
- * masking again from the same spans.
475
- *
476
- * The service reports offsets in code points and this SDK slices UTF-16
477
- * units, so the slice check is what stands between an emoji in the document
478
- * and a rebuilt text with the wrong characters masked.
479
- */
480
- async remaskCloudResult(result, text, opts) {
481
- const { CloudError } = await Promise.resolve().then(() => __importStar(require("./cloud/errors.js")));
482
- for (const det of result.detections) {
483
- if (text.slice(det.start, det.end) !== det.text) {
484
- throw new CloudError("span offsets do not match the document; cannot apply tokenize/allowlist locally");
485
- }
486
- }
487
- [result.detections, result.exempted] = applyAllowlist(text, result.detections, opts.allowed, opts.domains);
488
- const tokenMapper = opts.tokenize ? new TokenMapper(text, result.detections) : null;
489
- result.redactedText = applyReplacements(text, result.detections, this.labelFor(false, tokenMapper));
490
- result.tokens = tokenMapper ? tokenMapper.tokens : {};
556
+ const client = new CloudClient();
557
+ // Dates are always on, whatever detectDates says: that is the rules output
558
+ // the model was trained against, and a date of birth the rules can place
559
+ // has no reason to travel. It is the one ignored option that is safe to
560
+ // ignore — it can only cause MORE to be detected, never less.
561
+ //
562
+ // No allowlist and no tokens on this pass. An exemption says what the
563
+ // caller wants back, not what may leave; both are applied below, after the
564
+ // response, to the merged spans.
565
+ const local = this.redactRules(text, { countries, detectDates: true, cache: options.cache }, new Map(), new Map());
566
+ const [masked, labels] = maskForCloud(text, local.detections);
567
+ // The client converts the service's code-point offsets to UTF-16 units of
568
+ // the text it sent, so everything from here on is in one unit.
569
+ const remote = await client.redact(masked, { country: countries[0] });
570
+ // A new array throughout: `local` may be the cached rules result.
571
+ const [detections, exempted] = applyAllowlist(text, [...local.detections, ...ontoOriginal(remote.detections, masked, text, labels, CloudError)], allowed, domains);
572
+ detections.sort((a, b) => a.start - b.start || b.end - a.end);
573
+ const tokenMapper = options.tokenize ? new TokenMapper(text, detections) : null;
574
+ return {
575
+ ...local,
576
+ redactedText: applyReplacements(text, detections, this.labelFor(false, tokenMapper)),
577
+ detections,
578
+ source: "cloud",
579
+ tokens: tokenMapper ? tokenMapper.tokens : {},
580
+ exempted,
581
+ };
491
582
  }
492
583
  redact(text, options = {}) {
493
584
  checkCountryArg(options.countries, "countries");
@@ -508,12 +599,16 @@ class EuRedact {
508
599
  if (requestedMode !== "rules") {
509
600
  throw new Error(`unknown mode ${JSON.stringify(requestedMode)}: expected "rules" or "cloud"`);
510
601
  }
602
+ return this.redactRules(text, options, allowed, domains);
603
+ }
604
+ /** The local pipeline: normalise, detect, exempt, mask. */
605
+ redactRules(text, options, allowed, domains) {
511
606
  if (text.length > this.maxInputLength) {
512
607
  throw new Error(`Input text length (${text.length.toLocaleString()} chars) exceeds the maximum ` +
513
608
  `(${this.maxInputLength.toLocaleString()} chars). Split the input or ` +
514
609
  `increase maxInputLength when constructing EuRedact.`);
515
610
  }
516
- const { countries = null, countryHint = null, context = null, chunkOffset = 0, mode = "rules", referentialIntegrity = false, tokenize = false, detectDates = false, } = options;
611
+ const { countries = null, countryHint = null, context = null, chunkOffset = 0, referentialIntegrity = false, tokenize = false, detectDates = false, } = options;
517
612
  // A context makes the result depend on evidence from other chunks, so the
518
613
  // text no longer identifies the result. Caching is disabled rather than
519
614
  // keyed on the context, whose contents change as chunks arrive.
@@ -531,7 +626,7 @@ class EuRedact {
531
626
  // separator, and two different lists must never share a key.
532
627
  const allowKey = (allowed.size ? JSON.stringify([...allowed.keys()].sort()) : "")
533
628
  + (domains.size ? "|dom=" + JSON.stringify([...domains.keys()].sort()) : "");
534
- const cacheMode = `${mode}|dates=${detectDates}|hint=${hintKey}|ri=${referentialIntegrity}|tok=${tokenize}|allow=${allowKey}`;
629
+ const cacheMode = `rules|dates=${detectDates}|hint=${hintKey}|ri=${referentialIntegrity}|tok=${tokenize}|allow=${allowKey}`;
535
630
  let cacheKey;
536
631
  if (cache) {
537
632
  cacheKey = this.cache.key(normalizedText, countriesTuple, cacheMode);
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * [CLOUD EXTENSION] The cloud client.
3
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
4
+ * Speaks to the euRedact inference service: a fine-tuned model asked only
5
+ * *what did the rules miss?* The service returns located spans, so this
7
6
  * module's job is transport and translation, not detection.
8
7
  *
8
+ * It sends the text it is given, as given. `redactAsync(text, { mode: "cloud" })`
9
+ * is what runs the rules engine first and hands this client only the masked
10
+ * text; call the client directly and whatever you pass is what leaves the
11
+ * machine. The spans that come back index the text that was sent.
12
+ *
9
13
  * Three things it hides from the caller:
10
14
  *
11
15
  * - **Whether the document finished inside the service's sync window.** A short
@@ -26,7 +30,6 @@ export interface CloudRedactOptions {
26
30
  country: string;
27
31
  language?: string;
28
32
  priority?: "interactive" | "batch";
29
- rulesOnly?: boolean;
30
33
  idempotencyKey?: string;
31
34
  /** Injectable for tests. Defaults to the platform `fetch`. */
32
35
  fetchImpl?: typeof fetch;
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * [CLOUD EXTENSION] The cloud client.
3
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
4
+ * Speaks to the euRedact inference service: a fine-tuned model asked only
5
+ * *what did the rules miss?* The service returns located spans, so this
7
6
  * module's job is transport and translation, not detection.
8
7
  *
8
+ * It sends the text it is given, as given. `redactAsync(text, { mode: "cloud" })`
9
+ * is what runs the rules engine first and hands this client only the masked
10
+ * text; call the client directly and whatever you pass is what leaves the
11
+ * machine. The spans that come back index the text that was sent.
12
+ *
9
13
  * Three things it hides from the caller:
10
14
  *
11
15
  * - **Whether the document finished inside the service's sync window.** A short
@@ -153,7 +157,6 @@ export class CloudClient {
153
157
  country: options.country.toUpperCase(),
154
158
  language: options.language ?? "",
155
159
  priority: options.priority ?? "interactive",
156
- rules_only: options.rulesOnly ?? false,
157
160
  });
158
161
  let attempt = 0;
159
162
  for (;;) {