feat: apply direct identifier replacements before rewrite LLM call#208
feat: apply direct identifier replacements before rewrite LLM call#208asteier2026 wants to merge 6 commits into
Conversation
Direct identifiers are now substituted programmatically from the replacement map before the rewrite LLM sees the text, ensuring all occurrences are replaced consistently without relying on the LLM to apply a <replacement_map> block. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: asteier2026 <asteier@nvidia.com>
Greptile SummaryThis PR moves the application of
Confidence Score: 4/5Safe to merge with one fix: the silent exception fallback in _apply_direct_replacements can leave replace-method entities completely unprotected in the LLM output. The programmatic pre-replacement approach is solid and the single-pass regex correctly avoids cascade. However, if _apply_direct_replacements throws, the fallback passes unmodified PII-containing text to the LLM while _format_rewrite_disposition_block has already stripped replace entities from the disposition block and the prompt no longer carries a replacement map — meaning those entities reach the final output with no anonymization applied at all. src/anonymizer/engine/rewrite/rewrite_generation.py — specifically the exception handling in _apply_direct_replacements (lines 228-231). Important Files Changed
Reviews (4): Last reviewed commit: "fix: normalize Unicode whitespace when f..." | Re-trigger Greptile |
Sequential str.replace() calls could incorrectly replace a synthetic value that happened to match another entity's original string (e.g. Alice→Bob then Bob→Carlos making Alice appear as Carlos). A combined regex alternation matches all originals simultaneously, eliminating the cascade. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: asteier2026 <asteier@nvidia.com>
Moved the missing-map warning into _get_replace_pairs so the disposition is parsed in one place. _apply_direct_replacements no longer re-parses COL_SENSITIVITY_DISPOSITION when _get_replace_pairs returns an empty list. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: asteier2026 <asteier@nvidia.com>
The LLM generating the replacement map occasionally normalises unusual Unicode whitespace (e.g. U+202F narrow no-break space) to a regular space in the original field. The exact-match lookup then misses the entity, triggering the unprotected warning. Add _normalize_ws and a second-pass lookup so that if the exact match fails, a whitespace-normalised comparison is tried. When a normalised match is found the disposition entity value (which reflects what is actually in the text) is used as the substitution key. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: asteier2026 <asteier@nvidia.com>
…cefully Any failure (malformed disposition, bad replacement map, regex error) previously caused the entire record to be skipped. Now the error is logged and the original text is passed through unchanged so the LLM rewrite step can still run. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: asteier2026 <asteier@nvidia.com>
The LLM generating the replacement map normalises unusual Unicode whitespace (e.g. U+202F narrow no-break space) to a regular space in the original field. _filter_replacement_map_to_input_entities was using an exact match against the detected entity values, so these entries were silently dropped from the map. Add a whitespace-normalized fallback: when the exact (original, label) pair is not in allowed_pairs, try a normalized comparison and, if it matches, rewrite original to the canonical detected value so all downstream lookups succeed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: asteier2026 <asteier@nvidia.com>
| try: | ||
| pairs = _get_replace_pairs(row) | ||
| if pairs: | ||
| sorted_pairs = sorted(pairs, key=lambda p: len(p[0]), reverse=True) | ||
| pattern = re.compile("|".join(re.escape(original) for original, _ in sorted_pairs)) | ||
| lookup = dict(sorted_pairs) | ||
| plain_text = pattern.sub(lambda m: lookup[m.group(0)], plain_text) | ||
| tagged_text = pattern.sub(lambda m: lookup[m.group(0)], tagged_text) | ||
| except Exception: | ||
| logger.warning("Direct replacement failed; passing text through unchanged.", exc_info=True) | ||
| row[COL_PREREPLACE_TEXT] = plain_text | ||
| row[COL_PREREPLACE_TAGGED_TEXT] = tagged_text |
There was a problem hiding this comment.
Silent exception leaves
replace entities completely unprotected
When the try block throws, plain_text and tagged_text fall back to the originals (with PII), but _format_rewrite_disposition_block has already run and excluded all replace entities from COL_REWRITE_DISPOSITION_BLOCK. The LLM prompt also no longer contains a replacement map. The net effect is that on any exception the LLM sees PII-containing text with zero instructions to anonymize the affected entities — they flow through to COL_REWRITTEN_TEXT completely unprotected.
In the old code the LLM had the replacement map as a fallback; that safety net no longer exists. Consider either re-raising (let the record fail explicitly) or, at minimum, logging at ERROR level and setting a failure flag so downstream callers can skip or quarantine the row rather than emit unprotected PII.
Summary
the replacement map before the rewrite LLM call, using a single-pass regex to prevent cascade
replacements (e.g. Alice→Bob→Carlos)
replacements
_get_replace_pairs to handle LLM-normalised Unicode whitespace (e.g. U+202F → U+0020) in entity values
dropping the record
Motivation
The rewrite LLM was inconsistently applying replacement map entries, especially for entities that
appear multiple times. Programmatic pre-replacement guarantees all occurrences are substituted before
the LLM sees the text. The whitespace fixes handle cases where the LLM normalises unusual Unicode
whitespace in entity values, which caused map lookups to silently miss.