Skip to content

feat: apply direct identifier replacements before rewrite LLM call#208

Open
asteier2026 wants to merge 7 commits into
mainfrom
asteier2026/feature/rewrite-programmatic-prereplace
Open

feat: apply direct identifier replacements before rewrite LLM call#208
asteier2026 wants to merge 7 commits into
mainfrom
asteier2026/feature/rewrite-programmatic-prereplace

Conversation

@asteier2026

@asteier2026 asteier2026 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds COL_PREREPLACE_TEXT and COL_PREREPLACE_TAGGED_TEXT constants
  • Adds _apply_direct_replacements generator that programmatically substitutes direct identifiers from
    the replacement map before the rewrite LLM call, using a single-pass regex to prevent cascade
    replacements (e.g. Alice→Bob→Carlos)
  • Removes <replacement_map> block from the rewrite prompt — the LLM no longer needs to apply
    replacements
  • Updates repair.py to use pre-replaced text as the repair baseline
  • Removes COL_REPLACEMENT_MAP_FOR_PROMPT (no longer needed)
  • Adds whitespace-normalized fallback matching in both _filter_replacement_map_to_input_entities and
    _get_replace_pairs to handle LLM-normalised Unicode whitespace (e.g. U+202F → U+0020) in entity values
  • _apply_direct_replacements falls back to passing text through unchanged on error rather than
    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.

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>
@asteier2026
asteier2026 requested a review from a team as a code owner July 2, 2026 15:52
@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR moves the substitution of replace-method entities out of the LLM rewrite prompt and into a deterministic pre-processing step (_apply_direct_replacements), guaranteeing all occurrences are substituted before the LLM ever sees the text. It also adds Unicode-whitespace-normalised fallback matching in both the replace-map filter and the new pre-replacement lookup, handling cases where the replacement-map LLM normalises unusual code points.

  • Removes <replacement_map> from the rewrite prompt and COL_REPLACEMENT_MAP_FOR_PROMPT; replace entities are now fully handled by a single-pass regex that prevents cascade substitutions (e.g. Alice→Bob→Carlos).
  • Adds COL_PREREPLACE_TEXT / COL_PREREPLACE_TAGGED_TEXT columns and updates repair.py to use the pre-replaced text as the repair baseline, so the repair LLM never sees original PII.
  • The _get_replace_pairs helper returns [] with only a WARNING log when the replacement map is absent or has no matching entry for a required entity; since replace entities are excluded from the LLM prompt, this path silently passes PII-containing text through with no protection instructions — contradicting the documented contract in _apply_direct_replacements.

Confidence Score: 4/5

The core pre-replacement logic is correct, but a documented safety contract in _apply_direct_replacements is not fully enforced: when the replacement map is missing or has no entry for a required entity, the function writes PII-containing text through to COL_PREREPLACE_TEXT with no LLM instructions to protect those entities.

The single-pass regex, cascade-prevention, and Unicode whitespace fallback work as designed. The gap is that _get_replace_pairs returns an empty list (with only a warning) in two failure modes — map absent, or entity unmatched — and _apply_direct_replacements does not detect this partial-failure state and raise, despite the docstring explicitly promising it will. replace entities are excluded from the LLM prompt, so the silent passthrough leaves them completely unprotected end-to-end.

src/anonymizer/engine/rewrite/rewrite_generation.py — specifically the interaction between _get_replace_pairs and _apply_direct_replacements around the empty-pairs paths.

Important Files Changed

Filename Overview
src/anonymizer/engine/rewrite/rewrite_generation.py Core of the PR: adds _apply_direct_replacements with single-pass regex and whitespace-normalised fallback matching; removes LLM replacement map from prompt. Docstring promises to raise on failure but _get_replace_pairs silently returns [] (with only a warning) when the map is missing or has no entry for required entities, leaving PII unprotected.
src/anonymizer/engine/replace/llm_replace_workflow.py Adds whitespace-normalised fallback to _filter_replacement_map_to_input_entities so LLM-normalised Unicode whitespace in original fields still matches canonical entity values. Logic is sound; minor duplication of _normalize_ws via a lambda.
src/anonymizer/engine/rewrite/repair.py Switches repair prompt baseline from raw COL_TEXT to COL_PREREPLACE_TEXT, ensuring the repair LLM never sees original PII for replaced entities. Change is consistent with the overall pre-replacement strategy.
src/anonymizer/engine/constants.py Replaces COL_REPLACEMENT_MAP_FOR_PROMPT with COL_PREREPLACE_TEXT and COL_PREREPLACE_TAGGED_TEXT constants. Straightforward rename/addition.
tests/engine/test_rewrite_generation.py Tests updated to cover the new pre-replacement path including cascade prevention, Unicode whitespace fallback, and multi-occurrence replacement. Missing an end-to-end test that verifies the unmatched-entity warning path does not silently emit PII through to COL_PREREPLACE_TEXT.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Row as Row Data
    participant FDB as _format_rewrite_disposition_block
    participant ADR as _apply_direct_replacements
    participant GRP as _get_replace_pairs
    participant LLM as Rewrite LLM
    participant ERT as _extract_rewritten_text

    Row->>FDB: COL_SENSITIVITY_DISPOSITION
    FDB-->>Row: COL_REWRITE_DISPOSITION_BLOCK (replace entities excluded)

    Row->>ADR: COL_TEXT, COL_TAGGED_TEXT, COL_SENSITIVITY_DISPOSITION, COL_REPLACEMENT_MAP
    ADR->>GRP: row
    alt replace_values empty
        GRP-->>ADR: []
    else raw_map falsy
        GRP-->>ADR: [] + WARNING log
    else entries matched
        GRP-->>ADR: [(original, synthetic), ...]
    end
    ADR->>ADR: single-pass regex sub (longest-first, no cascade)
    ADR-->>Row: COL_PREREPLACE_TEXT / COL_PREREPLACE_TAGGED_TEXT

    Row->>LLM: COL_PREREPLACE_TAGGED_TEXT + COL_REWRITE_DISPOSITION_BLOCK
    LLM-->>Row: COL_FULL_REWRITE

    Row->>ERT: COL_FULL_REWRITE
    ERT-->>Row: COL_REWRITTEN_TEXT
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Row as Row Data
    participant FDB as _format_rewrite_disposition_block
    participant ADR as _apply_direct_replacements
    participant GRP as _get_replace_pairs
    participant LLM as Rewrite LLM
    participant ERT as _extract_rewritten_text

    Row->>FDB: COL_SENSITIVITY_DISPOSITION
    FDB-->>Row: COL_REWRITE_DISPOSITION_BLOCK (replace entities excluded)

    Row->>ADR: COL_TEXT, COL_TAGGED_TEXT, COL_SENSITIVITY_DISPOSITION, COL_REPLACEMENT_MAP
    ADR->>GRP: row
    alt replace_values empty
        GRP-->>ADR: []
    else raw_map falsy
        GRP-->>ADR: [] + WARNING log
    else entries matched
        GRP-->>ADR: [(original, synthetic), ...]
    end
    ADR->>ADR: single-pass regex sub (longest-first, no cascade)
    ADR-->>Row: COL_PREREPLACE_TEXT / COL_PREREPLACE_TAGGED_TEXT

    Row->>LLM: COL_PREREPLACE_TAGGED_TEXT + COL_REWRITE_DISPOSITION_BLOCK
    LLM-->>Row: COL_FULL_REWRITE

    Row->>ERT: COL_FULL_REWRITE
    ERT-->>Row: COL_REWRITTEN_TEXT
Loading

Reviews (5): Last reviewed commit: "fix:raise error if no replace" | Re-trigger Greptile

Comment thread src/anonymizer/engine/rewrite/rewrite_generation.py Outdated
Comment thread src/anonymizer/engine/rewrite/rewrite_generation.py Outdated
Comment thread tests/engine/test_rewrite_generation.py
asteier2026 and others added 5 commits July 2, 2026 09:02
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>
Comment on lines +220 to +231
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made fix

Comment on lines +206 to 230
def _apply_direct_replacements(row: dict[str, Any]) -> dict[str, Any]:
"""Programmatically replace direct identifier entities before the rewrite LLM call.

Applies each (original → synthetic) pair as a global string replacement on both
the plain text (COL_TEXT → COL_PREREPLACE_TEXT) and the tagged text
(COL_TAGGED_TEXT → COL_PREREPLACE_TAGGED_TEXT). A single-pass regex substitution
ensures all occurrences are replaced without cascade (a synthetic value matching
another entity's original is never re-substituted).

Raises on failure rather than falling back to unmodified text: replace entities are
excluded from COL_REWRITE_DISPOSITION_BLOCK, so a silent passthrough would send
PII-containing text to the LLM with no instructions to protect those entities.
"""
plain_text = str(row.get(COL_TEXT, ""))
tagged_text = str(row.get(COL_TAGGED_TEXT, ""))
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)
row[COL_PREREPLACE_TEXT] = plain_text
row[COL_PREREPLACE_TAGGED_TEXT] = tagged_text
return row

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Docstring promises to raise on failure, but PII still silently passes through

_apply_direct_replacements documents that it "Raises on failure rather than falling back to unmodified text", citing exactly the risk scenario: replace entities are excluded from COL_REWRITE_DISPOSITION_BLOCK, so a silent passthrough would emit PII with no LLM instructions. But _get_replace_pairs has two paths that return [] without raising:

  1. if not raw_map — logs a warning and returns []
  2. unmatched entities — logs a warning and returns partial pairs (possibly [])

When either path fires, pairs is empty or incomplete, the if pairs: branch is skipped, plain_text/tagged_text remain as the raw PII-containing originals, and both columns are written straight to COL_PREREPLACE_TEXT/COL_PREREPLACE_TAGGED_TEXT. The LLM then sees unredacted PII with zero instructions to protect it. The test test_get_replace_pairs_warns_on_unmatched_replace_entity explicitly asserts pairs == [] for this path, confirming the silent-passthrough survives.

Consider having _apply_direct_replacements compute replace_values (or receive it from _get_replace_pairs) and raise when required pairs are missing, consistent with the documented contract.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant