feat(anonymizer): expose confidence score in OperatorResult (#2059)

This commit is contained in:
Keshav
2026-08-03 14:54:03 +03:00
committed by GitHub
parent 2bb88d2adc
commit 6116c0685c
7 changed files with 149 additions and 33 deletions
+11 -11
View File
@@ -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