Skip to content

Commit aefa16a

Browse files
committed
fix(pii): keep context scoring for thresholded long texts and match data: case-insensitively
- Long-text NER windows also run the pattern entities when a score_threshold is set, so context-boosted matches clear it as they do on short texts - The data: URI scheme is matched case-insensitively - e2e asserts each name is masked as one <PERSON> span and is registered as test:pii-redaction:e2e
1 parent 994e540 commit aefa16a

5 files changed

Lines changed: 30 additions & 16 deletions

File tree

‎apps/pii/server.py‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -243,11 +243,18 @@ def _exceeds_nlp_length(text: str, language: str) -> bool:
243243
return limit is not None and len(text) > limit
244244

245245

246-
def _ner_window_entities(entities: list[str] | None, language: str) -> list[str]:
247-
"""The requested entities only spaCy NER produces and this language serves, or
248-
all of them when `entities` is empty (detect-all)."""
249-
ner = NER_ENTITIES & set(analyzer.get_supported_entities(language))
250-
return sorted(ner if not entities else ner.intersection(entities))
246+
def _window_entities(
247+
entities: list[str] | None, language: str, score_threshold: float | None
248+
) -> list[str]:
249+
"""The requested built-in entities this language serves that windows must find,
250+
or all of them when `entities` is empty (detect-all). Without a score_threshold
251+
that is only what spaCy NER produces; with one it also includes the pattern
252+
entities, whose context-boosted score can decide whether a match clears the
253+
threshold and needs the real NLP artifacts only a window has."""
254+
served = set(analyzer.get_supported_entities(language))
255+
if score_threshold is None or score_threshold <= 0:
256+
served &= NER_ENTITIES
257+
return sorted(served if not entities else served.intersection(entities))
251258

252259

253260
def _analyze_long(
@@ -263,13 +270,14 @@ def _analyze_long(
263270
Pattern, checksum, and custom recognizers have no length limit, so they run
264271
once over the whole text (against blank NLP artifacts, as the regex-only path
265272
does) and no match can straddle a cut. Only NER runs in overlapping windows
266-
(see NER_WINDOW_CHARS), asked for just its own entities. A span touching a
273+
(see NER_WINDOW_CHARS), asked for just its own entities, plus the pattern
274+
entities when a score_threshold makes context scoring matter (see
275+
`_window_entities`). A span touching a
267276
window's far edge may be cut short, so it is left to the next window, which
268277
sees it whole; a span ending inside the overlap before that edge was already
269278
found by the previous window and is skipped. The rest are shifted to
270-
whole-text offsets, and overlapping spans are left to the anonymizer, which
271-
merges them. Context words cannot raise a pattern match's score here, which
272-
matters only when a score_threshold is set."""
279+
whole-text offsets; a span found by both passes keeps its higher score, and
280+
overlapping spans are left to the anonymizer, which merges them."""
273281
entities = entities or None
274282
results = analyzer.analyze(
275283
text=text,
@@ -280,7 +288,7 @@ def _analyze_long(
280288
nlp_artifacts=_BLANK_ARTIFACTS.get(language),
281289
ad_hoc_recognizers=ad_hoc_recognizers or None,
282290
)
283-
window_entities = _ner_window_entities(entities, language)
291+
window_entities = _window_entities(entities, language, score_threshold)
284292
if not window_entities:
285293
return results
286294
step = NER_WINDOW_CHARS - NER_WINDOW_OVERLAP

‎apps/sim/lib/logs/execution/pii-redaction.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ describe('encoded binary content', () => {
170170
['wrapped at 76 columns', wrapped(76, '\n')],
171171
['wrapped at 64 columns with CRLF', `${wrapped(64, '\r\n')}\r\n`],
172172
['as a wrapped data URL', `data:application/pdf;base64,${wrapped(76, '\n')}`],
173+
['as a data URL with an uppercase scheme', `DATA:application/pdf;base64,${encodedFile}`],
173174
[
174175
'as a data URL with long metadata',
175176
`data:application/pdf;name=${'quarterly-report-'.repeat(40)}.pdf;base64,${encodedFile}`,

‎apps/sim/lib/logs/execution/pii-redaction.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ function base64Body(value: string): string | null {
6060
* masking cannot check such a value for PII, which lives inside the decoded file.
6161
*/
6262
function isEncodedBinary(value: string): boolean {
63-
if (value.startsWith('data:')) {
63+
if (/^data:/i.test(value)) {
6464
const comma = value.indexOf(',')
6565
return (
6666
comma !== -1 &&

‎apps/sim/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"test:scim:e2e": "bun run scripts/test-scim-e2e.ts",
2626
"test:sim-links:e2e": "bun run scripts/test-sim-links-e2e.ts",
2727
"test:people:e2e": "bun run scripts/test-people-e2e.ts",
28+
"test:pii-redaction:e2e": "bun --no-env-file scripts/test-pii-redaction-e2e.ts",
2829
"test:browser-extension:e2e": "bun --no-env-file scripts/test-browser-extension-e2e.ts",
2930
"test:isolated-vm-remote:e2e": "bun run scripts/test-isolated-vm-remote-e2e.ts",
3031
"test:mobile:e2e": "bun --no-env-file scripts/test-mobile-e2e.ts",

‎apps/sim/scripts/test-pii-redaction-e2e.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,10 @@ function assertNameMasked(masked: unknown, failedMarker: string): void {
9191
assert.equal(typeof masked, 'string', 'Expected the long text back as a string')
9292
const text = masked as string
9393
assert(!text.includes(failedMarker), 'The long text was scrubbed instead of masked')
94-
assert(!text.includes('Ada Lovelace'), 'The name at the end of the long text was not masked')
95-
assert(text.endsWith('before Friday.'), 'The end of the long text was lost')
96-
assert(text.includes('<PERSON>'), 'Expected a <PERSON> token where the name was')
94+
assert(
95+
text.endsWith('Please send questions to <PERSON> before Friday.'),
96+
'The name at the end of the long text was not masked as one <PERSON> span'
97+
)
9798
}
9899

99100
async function main(): Promise<void> {
@@ -166,8 +167,11 @@ async function main(): Promise<void> {
166167
`Presidio answered ${response.status}: ${body.slice(0, 200)}`
167168
)
168169
const [masked] = (JSON.parse(body) as { texts: string[] }).texts
169-
assert(!masked.includes('Grace Hopper'), 'The name across the first NER window edge survived')
170-
assert(!masked.includes('Ada Lovelace'), 'The name at the end survived')
170+
assert(
171+
masked.includes(`${lead}<PERSON> before Friday.`),
172+
'The name across the first NER window edge was not masked as one <PERSON> span'
173+
)
174+
assert(masked.endsWith('to <PERSON> before Friday.'), 'The name at the end survived')
171175
assert(!masked.includes('EMP 123456'), 'The custom pattern spanning whitespace survived')
172176
assert(masked.includes('<EMPLOYEE_ID>'), 'Expected the custom replacement')
173177
return { textChars: text.length }

0 commit comments

Comments
 (0)