From c10021500e4335f0fe612f055bda418fbcd9cae0 Mon Sep 17 00:00:00 2001 From: asteier2026 Date: Thu, 2 Jul 2026 10:29:50 -0700 Subject: [PATCH 1/8] bugfix: coerce low combined_risk_level to leave_as_is instead of rejecting The LLM occasionally outputs combined_risk_level='low' with a non-leave_as_is protection_method_suggestion. Previously this caused a ValidationError that dropped the entire record. Now the protection_method_suggestion is silently coerced to leave_as_is, consistent with the semantics of low combined risk. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: asteier2026 --- src/anonymizer/engine/schemas/rewrite.py | 6 ++---- tests/engine/test_schemas.py | 10 +++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/anonymizer/engine/schemas/rewrite.py b/src/anonymizer/engine/schemas/rewrite.py index 912281dd..e8fba565 100644 --- a/src/anonymizer/engine/schemas/rewrite.py +++ b/src/anonymizer/engine/schemas/rewrite.py @@ -150,10 +150,8 @@ def _validate_protection_consistency(self) -> EntityDispositionSchema: self.combined_risk_level == CombinedRiskLevel.low and self.protection_method_suggestion != ProtectionMethod.leave_as_is ): - raise ValueError( - f"Entity {self.id}: combined_risk_level='low' requires protection_method_suggestion='leave_as_is', " - f"got '{self.protection_method_suggestion}'" - ) + # LLM occasionally returns an inconsistent combination; coerce rather than reject. + self.protection_method_suggestion = ProtectionMethod.leave_as_is if ( self.combined_risk_level == CombinedRiskLevel.high and self.protection_method_suggestion == ProtectionMethod.leave_as_is diff --git a/tests/engine/test_schemas.py b/tests/engine/test_schemas.py index 3ceada56..168d017c 100644 --- a/tests/engine/test_schemas.py +++ b/tests/engine/test_schemas.py @@ -244,11 +244,11 @@ def mixed_disposition() -> SensitivityDispositionSchema: # EntityDispositionSchema — protection consistency -def test_entity_disposition_invalid_low_risk_but_not_leave_as_is() -> None: - with pytest.raises(ValidationError, match="combined_risk_level='low'"): - EntityDispositionSchema.model_validate( - _make_entity(combined_risk_level="low", protection_method_suggestion="replace") - ) +def test_entity_disposition_low_risk_non_leave_as_is_is_coerced() -> None: + entity = EntityDispositionSchema.model_validate( + _make_entity(combined_risk_level="low", protection_method_suggestion="replace") + ) + assert entity.protection_method_suggestion == "leave_as_is" def test_entity_disposition_invalid_high_risk_but_leave_as_is() -> None: From a4d8724613031824ec38f6f212f0679720fcf228 Mon Sep 17 00:00:00 2001 From: asteier2026 Date: Thu, 2 Jul 2026 11:55:29 -0700 Subject: [PATCH 2/8] bugfix: promote combined_risk_level to medium instead of suppressing protection When the LLM assigns combined_risk_level='low' alongside a non-leave_as_is protection method, trust the protection intent over the risk label. Promoting the risk level to medium preserves the model's decision to protect the entity rather than silently discarding it. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: asteier2026 --- src/anonymizer/engine/schemas/rewrite.py | 5 +++-- tests/engine/test_schemas.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/anonymizer/engine/schemas/rewrite.py b/src/anonymizer/engine/schemas/rewrite.py index e8fba565..ea41f385 100644 --- a/src/anonymizer/engine/schemas/rewrite.py +++ b/src/anonymizer/engine/schemas/rewrite.py @@ -150,8 +150,9 @@ def _validate_protection_consistency(self) -> EntityDispositionSchema: self.combined_risk_level == CombinedRiskLevel.low and self.protection_method_suggestion != ProtectionMethod.leave_as_is ): - # LLM occasionally returns an inconsistent combination; coerce rather than reject. - self.protection_method_suggestion = ProtectionMethod.leave_as_is + # Trust the protection intent over the risk label; promote risk to medium + # rather than suppressing the protection. + self.combined_risk_level = CombinedRiskLevel.medium if ( self.combined_risk_level == CombinedRiskLevel.high and self.protection_method_suggestion == ProtectionMethod.leave_as_is diff --git a/tests/engine/test_schemas.py b/tests/engine/test_schemas.py index 168d017c..47b20d2d 100644 --- a/tests/engine/test_schemas.py +++ b/tests/engine/test_schemas.py @@ -244,11 +244,12 @@ def mixed_disposition() -> SensitivityDispositionSchema: # EntityDispositionSchema — protection consistency -def test_entity_disposition_low_risk_non_leave_as_is_is_coerced() -> None: +def test_entity_disposition_low_risk_non_leave_as_is_promotes_risk_to_medium() -> None: entity = EntityDispositionSchema.model_validate( _make_entity(combined_risk_level="low", protection_method_suggestion="replace") ) - assert entity.protection_method_suggestion == "leave_as_is" + assert entity.combined_risk_level == "medium" + assert entity.protection_method_suggestion == "replace" def test_entity_disposition_invalid_high_risk_but_leave_as_is() -> None: From 1d3d6890d5f1fe8e5c85c87f8e6489b3237dc762 Mon Sep 17 00:00:00 2001 From: asteier2026 Date: Thu, 2 Jul 2026 12:00:24 -0700 Subject: [PATCH 3/8] fix: log warning when combined_risk_level is promoted due to LLM inconsistency Silent coercion made it impossible to detect in production whether the inconsistency was a rare blip or a systematic regression from a prompt or model change. The warning names the entity and both conflicting field values so it is actionable in logs. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: asteier2026 --- src/anonymizer/engine/schemas/rewrite.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/anonymizer/engine/schemas/rewrite.py b/src/anonymizer/engine/schemas/rewrite.py index ea41f385..84f68c51 100644 --- a/src/anonymizer/engine/schemas/rewrite.py +++ b/src/anonymizer/engine/schemas/rewrite.py @@ -38,10 +38,13 @@ from __future__ import annotations +import logging from enum import Enum from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, model_validator +logger = logging.getLogger("anonymizer.schemas.rewrite") + # --------------------------------------------------------------------------- # Domain # --------------------------------------------------------------------------- @@ -150,6 +153,13 @@ def _validate_protection_consistency(self) -> EntityDispositionSchema: self.combined_risk_level == CombinedRiskLevel.low and self.protection_method_suggestion != ProtectionMethod.leave_as_is ): + logger.warning( + "Entity %d (%r): combined_risk_level='low' conflicts with " + "protection_method_suggestion=%r; promoting risk to 'medium'.", + self.id, + self.entity_value, + self.protection_method_suggestion, + ) # Trust the protection intent over the risk label; promote risk to medium # rather than suppressing the protection. self.combined_risk_level = CombinedRiskLevel.medium From 6ee088a5ad20fb552da2571e56136ccac71a20e9 Mon Sep 17 00:00:00 2001 From: asteier2026 Date: Thu, 2 Jul 2026 12:01:33 -0700 Subject: [PATCH 4/8] test: parametrize low-risk coercion test over all four protection methods Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: asteier2026 --- tests/engine/test_schemas.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/engine/test_schemas.py b/tests/engine/test_schemas.py index 47b20d2d..8d14e7c1 100644 --- a/tests/engine/test_schemas.py +++ b/tests/engine/test_schemas.py @@ -244,12 +244,13 @@ def mixed_disposition() -> SensitivityDispositionSchema: # EntityDispositionSchema — protection consistency -def test_entity_disposition_low_risk_non_leave_as_is_promotes_risk_to_medium() -> None: +@pytest.mark.parametrize("method", ["replace", "generalize", "remove", "suppress_inference"]) +def test_entity_disposition_low_risk_non_leave_as_is_promotes_risk_to_medium(method: str) -> None: entity = EntityDispositionSchema.model_validate( - _make_entity(combined_risk_level="low", protection_method_suggestion="replace") + _make_entity(combined_risk_level="low", protection_method_suggestion=method) ) assert entity.combined_risk_level == "medium" - assert entity.protection_method_suggestion == "replace" + assert entity.protection_method_suggestion == method def test_entity_disposition_invalid_high_risk_but_leave_as_is() -> None: From e5a4b05637164745a2ade0112087c11ce62e4650 Mon Sep 17 00:00:00 2001 From: asteier2026 Date: Wed, 29 Jul 2026 10:41:22 -0700 Subject: [PATCH 5/8] fix: redact entity_value from coercion warning log to avoid PII exposure Replace entity_value with entity_label in the logger.warning call so raw PII strings are not written to log aggregators. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/anonymizer/engine/schemas/rewrite.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/anonymizer/engine/schemas/rewrite.py b/src/anonymizer/engine/schemas/rewrite.py index 84f68c51..25de169a 100644 --- a/src/anonymizer/engine/schemas/rewrite.py +++ b/src/anonymizer/engine/schemas/rewrite.py @@ -154,10 +154,10 @@ def _validate_protection_consistency(self) -> EntityDispositionSchema: and self.protection_method_suggestion != ProtectionMethod.leave_as_is ): logger.warning( - "Entity %d (%r): combined_risk_level='low' conflicts with " + "Entity %d (label=%r): combined_risk_level='low' conflicts with " "protection_method_suggestion=%r; promoting risk to 'medium'.", self.id, - self.entity_value, + self.entity_label, self.protection_method_suggestion, ) # Trust the protection intent over the risk label; promote risk to medium From 3ab0b7a34a8f5d402e95b9a3f56a76eb96ad82b2 Mon Sep 17 00:00:00 2001 From: asteier2026 Date: Tue, 11 Aug 2026 14:15:28 -0700 Subject: [PATCH 6/8] fix: assign .value in low-risk coercion to preserve use_enum_values serialization Post-validation assignment of CombinedRiskLevel.medium was storing the enum object instead of a plain string, inconsistent with use_enum_values=True. Use CombinedRiskLevel.medium.value so model_dump() returns a plain string on both the coerced and non-coerced paths. Adds assertion to the existing test. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/anonymizer/engine/schemas/rewrite.py | 2 +- tests/engine/test_schemas.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/anonymizer/engine/schemas/rewrite.py b/src/anonymizer/engine/schemas/rewrite.py index 25de169a..31c41289 100644 --- a/src/anonymizer/engine/schemas/rewrite.py +++ b/src/anonymizer/engine/schemas/rewrite.py @@ -162,7 +162,7 @@ def _validate_protection_consistency(self) -> EntityDispositionSchema: ) # Trust the protection intent over the risk label; promote risk to medium # rather than suppressing the protection. - self.combined_risk_level = CombinedRiskLevel.medium + self.combined_risk_level = CombinedRiskLevel.medium.value if ( self.combined_risk_level == CombinedRiskLevel.high and self.protection_method_suggestion == ProtectionMethod.leave_as_is diff --git a/tests/engine/test_schemas.py b/tests/engine/test_schemas.py index 8d14e7c1..32f37ba3 100644 --- a/tests/engine/test_schemas.py +++ b/tests/engine/test_schemas.py @@ -251,6 +251,10 @@ def test_entity_disposition_low_risk_non_leave_as_is_promotes_risk_to_medium(met ) assert entity.combined_risk_level == "medium" assert entity.protection_method_suggestion == method + # Coerced and non-coerced paths must produce the same serialization type (plain string, not enum). + dumped = entity.model_dump() + assert dumped["combined_risk_level"] == "medium" + assert isinstance(dumped["combined_risk_level"], str) def test_entity_disposition_invalid_high_risk_but_leave_as_is() -> None: From 290e21883ef47b1f7c76c511f78b3d3541ad4884 Mon Sep 17 00:00:00 2001 From: asteier2026 Date: Thu, 13 Aug 2026 08:06:41 -0700 Subject: [PATCH 7/8] fix: suppress type: ignore for use_enum_values post-validation assignment Assigning CombinedRiskLevel.medium.value (str) to a CombinedRiskLevel-typed field after validation requires a type: ignore[assignment] since the static type doesn't reflect Pydantic's use_enum_values=True runtime behavior. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/anonymizer/engine/schemas/rewrite.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/anonymizer/engine/schemas/rewrite.py b/src/anonymizer/engine/schemas/rewrite.py index 31c41289..66fb0b65 100644 --- a/src/anonymizer/engine/schemas/rewrite.py +++ b/src/anonymizer/engine/schemas/rewrite.py @@ -162,7 +162,7 @@ def _validate_protection_consistency(self) -> EntityDispositionSchema: ) # Trust the protection intent over the risk label; promote risk to medium # rather than suppressing the protection. - self.combined_risk_level = CombinedRiskLevel.medium.value + self.combined_risk_level = CombinedRiskLevel.medium.value # type: ignore[assignment] if ( self.combined_risk_level == CombinedRiskLevel.high and self.protection_method_suggestion == ProtectionMethod.leave_as_is From f6a52d6ca3823061a13b5ec526946d864cf45b58 Mon Sep 17 00:00:00 2001 From: asteier2026 Date: Fri, 14 Aug 2026 09:24:40 -0700 Subject: [PATCH 8/8] fix: use ty: ignore comment and type() is str in coercion test Replace type: ignore[assignment] with the ty-compatible form ty: ignore[invalid-assignment]. Change isinstance(..., str) to type(...) is str so the assertion correctly distinguishes a plain string from a CombinedRiskLevel enum (which subclasses str). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/anonymizer/engine/schemas/rewrite.py | 2 +- tests/engine/test_schemas.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/anonymizer/engine/schemas/rewrite.py b/src/anonymizer/engine/schemas/rewrite.py index 66fb0b65..e753662f 100644 --- a/src/anonymizer/engine/schemas/rewrite.py +++ b/src/anonymizer/engine/schemas/rewrite.py @@ -162,7 +162,7 @@ def _validate_protection_consistency(self) -> EntityDispositionSchema: ) # Trust the protection intent over the risk label; promote risk to medium # rather than suppressing the protection. - self.combined_risk_level = CombinedRiskLevel.medium.value # type: ignore[assignment] + self.combined_risk_level = CombinedRiskLevel.medium.value # type: ignore[assignment] # ty: ignore[invalid-assignment] if ( self.combined_risk_level == CombinedRiskLevel.high and self.protection_method_suggestion == ProtectionMethod.leave_as_is diff --git a/tests/engine/test_schemas.py b/tests/engine/test_schemas.py index 32f37ba3..b93d6b2c 100644 --- a/tests/engine/test_schemas.py +++ b/tests/engine/test_schemas.py @@ -254,7 +254,7 @@ def test_entity_disposition_low_risk_non_leave_as_is_promotes_risk_to_medium(met # Coerced and non-coerced paths must produce the same serialization type (plain string, not enum). dumped = entity.model_dump() assert dumped["combined_risk_level"] == "medium" - assert isinstance(dumped["combined_risk_level"], str) + assert type(dumped["combined_risk_level"]) is str def test_entity_disposition_invalid_high_risk_but_leave_as_is() -> None: