document-pii-redactor / tests /test_text_transforms.py
deploy
Deploy from 81eae33
649ee03
Raw
History Blame Contribute Delete
7.75 kB
from document_pii_redactor.pseudonym import PseudonymMapping
from document_pii_redactor.text.redactor import TextPIISpan
from document_pii_redactor.text.transforms import (
anonymize_value, apply_anonymization, apply_pseudonyms, merge_adjacent_spans,
)
def _span(cat, start, end, l1, txt):
return TextPIISpan(category=cat, start=start, end=end, l1=l1, text=txt, score=0.9)
# ------------------------------------------------------------- deidentify --- #
def test_pseudonyms_are_consistent_within_text():
text = "John met Asha. John left."
spans = [
_span("primary_subject_name", 0, 4, "person", "John"),
_span("other_person_name", 9, 13, "person", "Asha"),
_span("primary_subject_name", 15, 19, "person", "John"),
]
m = PseudonymMapping()
assert apply_pseudonyms(text, spans, m) == "Person_1 met Person_2. Person_1 left."
def test_pseudonyms_continue_across_documents_with_shared_mapping():
m = PseudonymMapping()
page1 = apply_pseudonyms(
"John", [_span("primary_subject_name", 0, 4, "person", "John")], m)
page2 = apply_pseudonyms(
"Asha and John",
[_span("other_person_name", 0, 4, "person", "Asha"),
_span("primary_subject_name", 9, 13, "person", "John")], m)
assert page1 == "Person_1"
assert page2 == "Person_2 and Person_1"
def test_overlapping_spans_union_leaves_nothing_uncovered():
# Overlap must never leak: [0,4) + [2,6) covers all of "abcdef",
# so the whole range gets ONE pseudonym — no trailing "ef".
text = "abcdef"
spans = [
_span("email", 0, 4, "contact", "abcd"),
_span("email", 2, 6, "contact", "cdef"),
]
out = apply_pseudonyms(text, spans, PseudonymMapping())
assert out == "Email_1"
def test_same_start_overlap_widest_wins_redact_and_deid():
# Detector emitted a short and a long span at the same offset; keeping
# the shorter used to leave "ajesh Kumar Sharma" in the redacted output.
from document_pii_redactor.text.redactor import apply_mask
text = "Rajesh Kumar Sharma lives here."
spans = [
_span("primary_subject_name", 0, 6, "person", "Rajesh"),
_span("primary_subject_name", 0, 19, "person", "Rajesh Kumar Sharma"),
]
assert apply_mask(text, spans) == "[REDACTED] lives here."
out = apply_pseudonyms(text, spans, PseudonymMapping())
assert out == "Person_1 lives here."
def test_cross_category_overlap_unions_with_widest_category():
text = "UH00219834X"
spans = [
_span("other_id", 0, 3, "id", "UH0"),
_span("mrn_uhid", 0, 11, "id", "UH00219834X"),
]
from document_pii_redactor.text.transforms import union_overlapping_spans
(u,) = union_overlapping_spans(text, spans)
assert (u.start, u.end, u.category) == (0, 11, "mrn_uhid")
def test_split_name_spans_merge_into_one_pseudonym():
# BIO decoding sometimes restarts mid-entity ("Mr. John" + "Doe");
# whitespace-separated same-category spans must get ONE pseudonym.
text = "Patient: Mr. John Doe here"
spans = [
_span("primary_subject_name", 9, 17, "person", "Mr. John"),
_span("primary_subject_name", 18, 21, "person", "Doe"),
]
out = apply_pseudonyms(text, spans, PseudonymMapping())
assert out == "Patient: Person_1 here"
def test_comma_separated_names_stay_separate_people():
text = "John, Asha"
spans = [
_span("primary_subject_name", 0, 4, "person", "John"),
_span("primary_subject_name", 6, 10, "person", "Asha"),
]
out = apply_pseudonyms(text, spans, PseudonymMapping())
assert out == "Person_1, Person_2"
def test_adjacent_different_categories_do_not_merge():
text = "Indiranagar Karnataka"
spans = [
_span("city_district", 0, 11, "location", "Indiranagar"),
_span("state_province", 12, 21, "location", "Karnataka"),
]
merged = merge_adjacent_spans(text, spans)
assert len(merged) == 2
# -------------------------------------------------------------- anonymize --- #
def test_age_buckets():
assert anonymize_value("age", "45 yrs") == "40–49"
assert anonymize_value("age", "7") == "0–9"
assert anonymize_value("age", "elderly") == "[AGE]"
def test_dates_keep_year_only():
assert anonymize_value("date_of_birth", "12-03-1979") == "1979"
assert anonymize_value("other_date_time", "02/06/2026") == "2026"
assert anonymize_value("death_date", "12th March") == "[DATE]"
def test_coarse_geography_kept_fine_geography_collapsed():
assert anonymize_value("state_province", "Karnataka") == "Karnataka"
assert anonymize_value("country", "India") == "India"
assert anonymize_value("city_district", "Indiranagar") == "[LOCATION]"
assert anonymize_value("street_address", "14 MG Road") == "[LOCATION]"
assert anonymize_value("postal_zip_pin_code", "560038") == "[LOCATION]"
def test_direct_identifiers_collapse_without_numbering():
# Two different names both become the same unnumbered token — nothing to
# link back through, unlike de-identification.
assert anonymize_value("primary_subject_name", "John") == "[PERSON]"
assert anonymize_value("other_person_name", "Asha") == "[PERSON]"
assert anonymize_value("phone_mobile", "+91 98765 43210") == "[PHONE]"
assert anonymize_value("aadhaar_12_digit", "1234 5678 9012") == "[AADHAAR]"
def test_anonymize_collapses_adjacent_same_category_spans():
# Without merging this would read "[PERSON] [PERSON]".
text = "Mr. John Doe visited"
spans = [
_span("primary_subject_name", 0, 8, "person", "Mr. John"),
_span("primary_subject_name", 9, 12, "person", "Doe"),
]
assert apply_anonymization(text, spans) == "[PERSON] visited"
def test_apply_anonymization_end_to_end():
text = "John, 45 yrs, DOB 12-03-1979, Indiranagar, Karnataka"
spans = [
_span("primary_subject_name", 0, 4, "person", "John"),
_span("age", 6, 12, "person", "45 yrs"),
_span("date_of_birth", 18, 28, "date_time", "12-03-1979"),
_span("city_district", 30, 41, "location", "Indiranagar"),
_span("state_province", 43, 52, "location", "Karnataka"),
]
assert apply_anonymization(text, spans) == \
"[PERSON], 40–49, DOB 1979, [LOCATION], Karnataka"
# ---------------------------------------------------------- hash strategy --- #
def test_hash_strategy_is_consistent_across_documents():
from document_pii_redactor.pseudonym import token_for
span = [_span("primary_subject_name", 0, 8, "person", "John Doe")]
# Two separate calls with FRESH mappings — counters would restart at
# Person_1 both times; hash tokens must agree because they derive from
# the value itself.
out1 = apply_pseudonyms("John Doe", span, PseudonymMapping(), strategy="hash")
out2 = apply_pseudonyms("John Doe", span, PseudonymMapping(), strategy="hash")
assert out1 == out2 == token_for("primary_subject_name", "John Doe")
def test_hash_strategy_records_mapping_and_merges_spans():
text = "Patient: Mr. John Doe here"
spans = [
_span("primary_subject_name", 9, 17, "person", "Mr. John"),
_span("primary_subject_name", 18, 21, "person", "Doe"),
]
m = PseudonymMapping()
out = apply_pseudonyms(text, spans, m, strategy="hash")
from document_pii_redactor.pseudonym import token_for
tok = token_for("primary_subject_name", "Mr. John Doe")
assert out == f"Patient: {tok} here" # merged -> ONE token
assert m.entries["Person"] == {tok: "Mr. John Doe"}
def test_unknown_strategy_raises():
import pytest
with pytest.raises(ValueError, match="strategy"):
apply_pseudonyms("x", [], PseudonymMapping(), strategy="vault")