mirror of
https://github.com/data-privacy-stack/presidio.git
synced 2026-09-21 13:38:05 -05:00
feat(anonymizer): expose confidence score in OperatorResult (#2059)
This commit is contained in:
@@ -31,8 +31,8 @@ def test_given_anonymize_called_with_valid_request_then_expected_valid_response_
|
||||
{
|
||||
"text": "hello world, my name is ANONYMIZED. My number is: 03445****",
|
||||
"items": [
|
||||
{"operator": "mask", "entity_type": "PHONE_NUMBER", "start": 50, "end": 59, "text":"03445****"},
|
||||
{"operator": "replace", "entity_type": "NAME", "start": 24, "end": 34, "text":"ANONYMIZED"}
|
||||
{"operator": "mask", "entity_type": "PHONE_NUMBER", "start": 50, "end": 59, "text":"03445****", "score": 0.95},
|
||||
{"operator": "replace", "entity_type": "NAME", "start": 24, "end": 34, "text":"ANONYMIZED", "score": 0.8}
|
||||
]
|
||||
}
|
||||
"""
|
||||
@@ -146,7 +146,7 @@ def test_given_decrypt_called_with_encrypted_text_then_decrypted_text_returned()
|
||||
|
||||
response_status, response_content = deanonymize(json.dumps(request_body))
|
||||
|
||||
expected_response = """{"text": "text_for_encryption", "items": [{"start": 0, "end": 19, "operator":"decrypt", "text": "text_for_encryption","entity_type":"NUMBER"}]}"""
|
||||
expected_response = """{"text": "text_for_encryption", "items": [{"start": 0, "end": 19, "operator":"decrypt", "text": "text_for_encryption","entity_type":"NUMBER", "score": null}]}"""
|
||||
|
||||
assert response_status == 200
|
||||
assert equal_json_strings(expected_response, response_content)
|
||||
@@ -298,8 +298,8 @@ def test_keep_name():
|
||||
{
|
||||
"text": "hello world, my name is Jane Doe. My number is: <PHONE_NUMBER>",
|
||||
"items": [
|
||||
{"operator": "replace", "entity_type": "PHONE_NUMBER", "start": 48, "end": 62, "text":"<PHONE_NUMBER>"},
|
||||
{"operator": "keep", "entity_type": "NAME", "start": 24, "end": 32, "text":"Jane Doe"}
|
||||
{"operator": "replace", "entity_type": "PHONE_NUMBER", "start": 48, "end": 62, "text":"<PHONE_NUMBER>", "score": 0.95},
|
||||
{"operator": "keep", "entity_type": "NAME", "start": 24, "end": 32, "text":"Jane Doe", "score": 0.8}
|
||||
]
|
||||
}
|
||||
"""
|
||||
@@ -330,8 +330,8 @@ def test_overlapping_keep_first():
|
||||
{
|
||||
"text": "I'm George Washington<LOCATION>",
|
||||
"items": [
|
||||
{"operator": "replace", "entity_type": "LOCATION", "start": 21, "end": 31, "text":"<LOCATION>"},
|
||||
{"operator": "keep", "entity_type": "NAME", "start": 4, "end": 21, "text":"George Washington"}
|
||||
{"operator": "replace", "entity_type": "LOCATION", "start": 21, "end": 31, "text":"<LOCATION>", "score": 0.8},
|
||||
{"operator": "keep", "entity_type": "NAME", "start": 4, "end": 21, "text":"George Washington", "score": 0.8}
|
||||
]
|
||||
}
|
||||
"""
|
||||
@@ -362,8 +362,8 @@ def test_overlapping_keep_second():
|
||||
{
|
||||
"text": "I'm <NAME>Washington Square Park",
|
||||
"items": [
|
||||
{"operator": "keep", "entity_type": "LOCATION", "start": 10, "end": 32, "text":"Washington Square Park"},
|
||||
{"operator": "replace", "entity_type": "NAME", "start": 4, "end": 10, "text":"<NAME>"}
|
||||
{"operator": "keep", "entity_type": "LOCATION", "start": 10, "end": 32, "text":"Washington Square Park", "score": 0.8},
|
||||
{"operator": "replace", "entity_type": "NAME", "start": 4, "end": 10, "text":"<NAME>", "score": 0.8}
|
||||
]
|
||||
}
|
||||
"""
|
||||
@@ -393,8 +393,8 @@ def test_overlapping_keep_both():
|
||||
{
|
||||
"text": "I'm George WashingtonWashington Square Park",
|
||||
"items": [
|
||||
{"operator": "keep", "entity_type": "LOCATION", "start": 21, "end": 43, "text":"Washington Square Park"},
|
||||
{"operator": "keep", "entity_type": "NAME", "start": 4, "end": 21, "text":"George Washington"}
|
||||
{"operator": "keep", "entity_type": "LOCATION", "start": 21, "end": 43, "text":"Washington Square Park", "score": 0.8},
|
||||
{"operator": "keep", "entity_type": "NAME", "start": 4, "end": 21, "text":"George Washington", "score": 0.8}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -56,7 +56,7 @@ def test_given_text_with_pii_then_analyze_and_anonymize_successfully():
|
||||
"analyzer_results": analyzer_data,
|
||||
}
|
||||
|
||||
expected_response = """{"text": "<PERSON> drivers license is AC43****", "items": [{"operator": "mask", "entity_type": "US_DRIVER_LICENSE", "start": 28, "end": 36, "text": "AC43****"}, {"operator": "replace", "entity_type": "PERSON", "start": 0, "end": 8, "text": "<PERSON>"}]}"""
|
||||
expected_response = """{"text": "<PERSON> drivers license is AC43****", "items": [{"operator": "mask", "entity_type": "US_DRIVER_LICENSE", "start": 28, "end": 36, "text": "AC43****", "score": 0.6499999999999999}, {"operator": "replace", "entity_type": "PERSON", "start": 0, "end": 8, "text": "<PERSON>", "score": 0.85}]}"""
|
||||
|
||||
anonymize_and_assert(anonymizer_request, expected_response)
|
||||
|
||||
@@ -96,7 +96,7 @@ def test_given_a_correct_analyze_input_high_threashold_then_anonymize_partially(
|
||||
"analyzer_results": analyzer_data,
|
||||
}
|
||||
|
||||
expected_response = """{"text": "<PERSON> drivers license is AC432223", "items": [{"operator": "replace", "entity_type": "PERSON", "start": 0, "end": 8, "text": "<PERSON>"}]}"""
|
||||
expected_response = """{"text": "<PERSON> drivers license is AC432223", "items": [{"operator": "replace", "entity_type": "PERSON", "start": 0, "end": 8, "text": "<PERSON>", "score": 0.85}]}"""
|
||||
|
||||
anonymize_and_assert(anonymizer_request, expected_response)
|
||||
|
||||
@@ -142,7 +142,7 @@ def test_given_a_correct_analyze_input_with_high_threshold_and_unmatched_entitie
|
||||
"analyzer_results": analyzer_data,
|
||||
}
|
||||
|
||||
expected_response = """{"text": "<PERSON> drivers license is AC432223", "items": [{"operator": "replace", "entity_type": "PERSON", "start": 0, "end": 8, "text": "<PERSON>"}]}"""
|
||||
expected_response = """{"text": "<PERSON> drivers license is AC432223", "items": [{"operator": "replace", "entity_type": "PERSON", "start": 0, "end": 8, "text": "<PERSON>", "score": 0.85}]}"""
|
||||
|
||||
anonymize_and_assert(anonymizer_request, expected_response)
|
||||
|
||||
@@ -177,7 +177,7 @@ def test_given_an_unknown_entity_then_anonymize_uses_defaults():
|
||||
"analyzer_results": analyzer_data,
|
||||
}
|
||||
|
||||
expected_response = """{"text": "<PERSON> drivers license is <US_DRIVER_LICENSE>", "items": [{"operator": "replace", "entity_type": "US_DRIVER_LICENSE", "start": 28, "end": 47, "text": "<US_DRIVER_LICENSE>"}, {"operator": "replace", "entity_type": "PERSON", "start": 0, "end": 8, "text": "<PERSON>"}]}"""
|
||||
expected_response = """{"text": "<PERSON> drivers license is <US_DRIVER_LICENSE>", "items": [{"operator": "replace", "entity_type": "US_DRIVER_LICENSE", "start": 28, "end": 47, "text": "<US_DRIVER_LICENSE>", "score": 0.6499999999999999}, {"operator": "replace", "entity_type": "PERSON", "start": 0, "end": 8, "text": "<PERSON>", "score": 0.85}]}"""
|
||||
|
||||
anonymize_and_assert(anonymizer_request, expected_response)
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ class EngineBase(ABC):
|
||||
entity.entity_type,
|
||||
changed_text,
|
||||
operator_metadata.operator_name,
|
||||
score=getattr(entity, "score", None),
|
||||
)
|
||||
engine_result.add_item(result_item)
|
||||
|
||||
|
||||
@@ -22,11 +22,15 @@ class PIIEntity(ABC):
|
||||
|
||||
def __repr__(self):
|
||||
"""Return a string representation of the object."""
|
||||
return (
|
||||
f"start: {self.start}"
|
||||
f"end: {self.end},"
|
||||
repr_str = (
|
||||
f"start: {self.start}, "
|
||||
f"end: {self.end}, "
|
||||
f"entity_type: {self.entity_type}"
|
||||
)
|
||||
score = getattr(self, "score", None)
|
||||
if score is not None:
|
||||
repr_str = repr_str + f", score: {score}"
|
||||
return repr_str
|
||||
|
||||
def __gt__(self, other):
|
||||
"""Check one entity is greater then other by the text end index."""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Dict
|
||||
from typing import Dict, Optional
|
||||
|
||||
from presidio_anonymizer.entities import PIIEntity
|
||||
|
||||
@@ -13,10 +13,12 @@ class OperatorResult(PIIEntity):
|
||||
entity_type: str,
|
||||
text: str = None,
|
||||
operator: str = None,
|
||||
score: Optional[float] = None,
|
||||
):
|
||||
PIIEntity.__init__(self, start, end, entity_type)
|
||||
self.text = text
|
||||
self.operator = operator
|
||||
self.score = score
|
||||
|
||||
def __repr__(self):
|
||||
"""Return a string representation of the object."""
|
||||
@@ -24,7 +26,7 @@ class OperatorResult(PIIEntity):
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Return object as Dict."""
|
||||
return self.__dict__
|
||||
return dict(self.__dict__)
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the object."""
|
||||
@@ -58,6 +60,7 @@ class OperatorResult(PIIEntity):
|
||||
"entity_type":"PERSON",
|
||||
"text":"resulted_text",
|
||||
"operator":"encrypt",
|
||||
"score": 0.85
|
||||
}
|
||||
"""
|
||||
start = json.get("start")
|
||||
@@ -65,10 +68,12 @@ class OperatorResult(PIIEntity):
|
||||
entity_type = json.get("entity_type")
|
||||
text = json.get("text")
|
||||
operator = json.get("operator")
|
||||
score = json.get("score")
|
||||
return cls(
|
||||
start=start,
|
||||
end=end,
|
||||
entity_type=entity_type,
|
||||
text=text,
|
||||
operator=operator,
|
||||
score=float(score) if score is not None else None,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import re
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -27,7 +28,7 @@ def test_given_url_at_the_end_then_we_redact_is_successfully():
|
||||
]
|
||||
expected_result = (
|
||||
'{"text": "The url is ", "items": [{"start": 11, "end": 11, "entity_type": '
|
||||
'"URL", "text": "", "operator": "redact"}]}'
|
||||
'"URL", "text": "", "operator": "redact", "score": 1.0}]}'
|
||||
)
|
||||
run_engine_and_validate(text, anonymizer_config, analyzer_results, expected_result)
|
||||
|
||||
@@ -63,9 +64,9 @@ def test_given_name_and_phone_number_then_we_anonymize_correctly():
|
||||
expected_result = (
|
||||
'{"text": "hello world, my name is ********. My number is: '
|
||||
'03-******4", "items": [{"start": 48, "end": 57, "entity_type": '
|
||||
'"PHONE_NUMBER", "text": "03-******", "operator": "mask"}, '
|
||||
'"PHONE_NUMBER", "text": "03-******", "operator": "mask", "score": 0.95}, '
|
||||
'{"start": 24, "end": 32, "entity_type": "NAME", '
|
||||
'"text": "********", "operator": "mask"}]}'
|
||||
'"text": "********", "operator": "mask", "score": 0.8}]}'
|
||||
)
|
||||
run_engine_and_validate(text, anonymizer_config, analyzer_results, expected_result)
|
||||
|
||||
@@ -85,9 +86,9 @@ def test_given_name_and_phone_number_without_anonymizers_then_we_use_default():
|
||||
'{"text": "hello world, my name is <NAME>. My number is: '
|
||||
'<PHONE_NUMBER>4", "items": [{"start": 46, "end": 60, '
|
||||
'"entity_type": "PHONE_NUMBER", "text": "<PHONE_NUMBER>", '
|
||||
'"operator": "replace"}, {"start": 24, "end": 30, '
|
||||
'"operator": "replace", "score": 0.95}, {"start": 24, "end": 30, '
|
||||
'"entity_type": "NAME", "text": "<NAME>", '
|
||||
'"operator": "replace"}]}'
|
||||
'"operator": "replace", "score": 0.8}]}'
|
||||
)
|
||||
run_engine_and_validate(text, anonymizer_config, analyzer_results, expected_result)
|
||||
|
||||
@@ -106,9 +107,9 @@ def test_given_redact_and_replace_then_we_anonymize_successfully():
|
||||
'{"text": "hello world, my name is . My number is: '
|
||||
'<PHONE_NUMBER>4", "items": [{"start": 40, "end": 54, '
|
||||
'"entity_type": "PHONE_NUMBER", "text": "<PHONE_NUMBER>", '
|
||||
'"operator": "replace"}, {"start": 24, "end": 24, '
|
||||
'"operator": "replace", "score": 0.95}, {"start": 24, "end": 24, '
|
||||
'"entity_type": "NAME", "text": "", "operator": '
|
||||
'"redact"}]}'
|
||||
'"redact", "score": 0.8}]}'
|
||||
)
|
||||
run_engine_and_validate(text, anonymizer_config, analyzer_results, expected_result)
|
||||
|
||||
@@ -128,13 +129,13 @@ def test_given_intersecting_entities_then_we_anonymize_correctly():
|
||||
'{"text": "hello world, my name is <FULL_NAME><LAST_NAME> My '
|
||||
'number is: <PHONE_NUMBER><SSN>4", "items": [{"start": 75, '
|
||||
'"end": 80, "entity_type": "SSN", "text": "<SSN>", '
|
||||
'"operator": "replace"}, {"start": 61, "end": 75, '
|
||||
'"operator": "replace", "score": 0.8}, {"start": 61, "end": 75, '
|
||||
'"entity_type": "PHONE_NUMBER", "text": "<PHONE_NUMBER>", '
|
||||
'"operator": "replace"}, {"start": 35, "end": 46, '
|
||||
'"operator": "replace", "score": 0.95}, {"start": 35, "end": 46, '
|
||||
'"entity_type": "LAST_NAME", "text": "<LAST_NAME>", '
|
||||
'"operator": "replace"}, {"start": 24, "end": 35, '
|
||||
'"operator": "replace", "score": 0.6}, {"start": 24, "end": 35, '
|
||||
'"entity_type": "FULL_NAME", "text": "<FULL_NAME>", '
|
||||
'"operator": "replace"}]}'
|
||||
'"operator": "replace", "score": 0.6}]}'
|
||||
)
|
||||
run_engine_and_validate(text, anonymizer_config, analyzer_results, expected_result)
|
||||
|
||||
@@ -149,7 +150,7 @@ def test_given_intersecting_the_same_entities_then_we_anonymize_correctly():
|
||||
expected_result = (
|
||||
'{"text": "hello world, my name is <FULL_NAME> My number is: 03-4453334", '
|
||||
'"items": [{"start": 24, "end": 35, "entity_type": "FULL_NAME",'
|
||||
' "text": "<FULL_NAME>", "operator": "replace"}]}'
|
||||
' "text": "<FULL_NAME>", "operator": "replace", "score": 0.6}]}'
|
||||
)
|
||||
run_engine_and_validate(text, anonymizer_config, analyzer_results, expected_result)
|
||||
|
||||
@@ -318,7 +319,6 @@ def test_when_hash_with_user_provided_salt_then_hash_is_reproducible():
|
||||
)
|
||||
def test_hash_with_known_salt_produces_expected_output(text, salt, hash_type, expected_hash):
|
||||
"""Test that hashing with a known salt produces expected deterministic output."""
|
||||
from presidio_anonymizer import AnonymizerEngine
|
||||
|
||||
params = {"hash_type": hash_type, "salt": salt}
|
||||
anonymizer_config = {"DEFAULT": OperatorConfig("hash", params)}
|
||||
@@ -339,6 +339,95 @@ def test_hash_with_known_salt_produces_expected_output(text, salt, hash_type, ex
|
||||
assert result2.items[0].text == expected_hash
|
||||
|
||||
|
||||
def test_given_single_entity_then_score_is_propagated_to_result():
|
||||
"""Score from analyzer result appears on the corresponding OperatorResult."""
|
||||
text = "My name is Jane Doe"
|
||||
anonymizer_config = {"PERSON": OperatorConfig("replace")}
|
||||
analyzer_results = [
|
||||
RecognizerResult(start=11, end=19, score=0.85, entity_type="PERSON"),
|
||||
]
|
||||
|
||||
engine = AnonymizerEngine()
|
||||
result = engine.anonymize(text, analyzer_results, anonymizer_config)
|
||||
|
||||
assert result.items[0].score == 0.85
|
||||
|
||||
|
||||
def test_given_multiple_entities_then_each_score_is_propagated_correctly():
|
||||
"""Each OperatorResult carries the score of its originating RecognizerResult."""
|
||||
text = "My name is Jane Doe. My number is 034453334"
|
||||
anonymizer_config = {}
|
||||
analyzer_results = [
|
||||
RecognizerResult(start=11, end=19, score=0.8, entity_type="NAME"),
|
||||
RecognizerResult(start=34, end=43, score=0.95, entity_type="PHONE_NUMBER"),
|
||||
]
|
||||
|
||||
engine = AnonymizerEngine()
|
||||
result = engine.anonymize(text, analyzer_results, anonymizer_config)
|
||||
|
||||
result_by_type = {item.entity_type: item for item in result.items}
|
||||
assert result_by_type["NAME"].score == 0.8
|
||||
assert result_by_type["PHONE_NUMBER"].score == 0.95
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"operator_name,operator_params",
|
||||
[
|
||||
("replace", {}),
|
||||
("redact", {}),
|
||||
("mask", {"masking_char": "*", "chars_to_mask": 4, "from_end": False}),
|
||||
("hash", {}),
|
||||
],
|
||||
)
|
||||
def test_given_different_operators_then_score_is_always_propagated(
|
||||
operator_name, operator_params
|
||||
):
|
||||
"""Score is propagated regardless of which operator is applied."""
|
||||
text = "My name is Jane Doe"
|
||||
anonymizer_config = {"NAME": OperatorConfig(operator_name, operator_params)}
|
||||
analyzer_results = [
|
||||
RecognizerResult(start=11, end=19, score=0.75, entity_type="NAME"),
|
||||
]
|
||||
|
||||
engine = AnonymizerEngine()
|
||||
result = engine.anonymize(text, analyzer_results, anonymizer_config)
|
||||
|
||||
assert result.items[0].score == 0.75
|
||||
|
||||
|
||||
def test_given_conflicting_entities_then_winning_entity_score_is_preserved():
|
||||
"""When conflict resolution drops an entity, the surviving entity keeps its own score."""
|
||||
text = "hello world, my name is Jane Doe"
|
||||
anonymizer_config = {}
|
||||
analyzer_results = [
|
||||
RecognizerResult(start=24, end=32, score=0.6, entity_type="FULL_NAME"),
|
||||
RecognizerResult(start=24, end=28, score=0.9, entity_type="FIRST_NAME"), # loses
|
||||
RecognizerResult(start=24, end=30, score=0.8, entity_type="NAME"), # loses
|
||||
]
|
||||
|
||||
engine = AnonymizerEngine()
|
||||
result = engine.anonymize(text, analyzer_results, anonymizer_config)
|
||||
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0].entity_type == "FULL_NAME"
|
||||
assert result.items[0].score == 0.6 # its own score, not the score of dropped entities
|
||||
|
||||
|
||||
def test_given_score_in_result_then_it_is_present_in_json_output():
|
||||
"""Score is included in to_json() serialized output."""
|
||||
text = "My name is Jane Doe"
|
||||
anonymizer_config = {}
|
||||
analyzer_results = [
|
||||
RecognizerResult(start=11, end=19, score=0.85, entity_type="NAME"),
|
||||
]
|
||||
|
||||
engine = AnonymizerEngine()
|
||||
result = engine.anonymize(text, analyzer_results, anonymizer_config)
|
||||
|
||||
output = json.loads(result.to_json())
|
||||
assert output["items"][0]["score"] == pytest.approx(0.85)
|
||||
|
||||
|
||||
def run_engine_and_validate(
|
||||
text: str, anonymizers_config, analyzer_results, expected_result
|
||||
):
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from presidio_anonymizer.entities import PIIEntity
|
||||
|
||||
|
||||
def test_pii_entity_repr_includes_score_when_present():
|
||||
entity = PIIEntity(start=0, end=5, entity_type="PERSON")
|
||||
entity.score = 0.85
|
||||
result = repr(entity)
|
||||
assert "score: 0.85" in result
|
||||
assert "start: 0" in result
|
||||
assert "entity_type: PERSON" in result
|
||||
|
||||
|
||||
def test_pii_entity_repr_omits_score_when_absent():
|
||||
entity = PIIEntity(start=0, end=5, entity_type="PERSON")
|
||||
result = repr(entity)
|
||||
assert "score" not in result
|
||||
assert "start: 0" in result
|
||||
Reference in New Issue
Block a user