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 thread src/anonymizer/engine/rewrite/rewrite_generation.py Outdated
Comment thread src/anonymizer/engine/rewrite/rewrite_generation.py
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))

@lipikaramaswamy lipikaramaswamy Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This global regex does not preserve the span-aware matching semantics used by the Substitute workflow. For example, Ann → Maria transforms Ann met Anna into Maria met Mariaa, whereas _apply_replacement_map_to_text() replaces only detected entity spans.

Can the rewrite path reuse or adapt the existing span-aware replacement logic—including for tagged text—and add this case as a regression test?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

One additional thought for the span-aware approach: could the regression test also confirm that every detected span selected for replace was actually transformed? That would give us an explicit coverage check while preserving the existing boundary-aware semantics.

unmatched = replace_values - matched
if unmatched:
logger.warning(
"Replace entities have no entry in the replacement map and will pass through unprotected: %s",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Small logging-safety suggestion: sorted(unmatched) includes raw detected entity values, so the PII being protected can be persisted in operational logs. If this becomes an exception, including the values there would have the same issue. Could we report only safe metadata such as the record ID, entity IDs or labels, and counts?

@lipikaramaswamy lipikaramaswamy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for moving direct replacement into deterministic preprocessing—the overall direction makes sense. I’m requesting changes for the two safety and correctness gaps discussed inline: incomplete replacement maps currently fail open after replace entities are omitted from the prompt, and the global regex can modify text outside detected entity spans. There is also a smaller logging-safety concern around including raw entity values. Fail-safe handling, span-aware replacement, and focused regression tests would put this in good shape for another look.

@asteier2026

Copy link
Copy Markdown
Contributor Author

Made these changes:

  • Plain text: uses _apply_replacement_map_to_text with entity spans from COL_FINAL_ENTITIES — only replaces at exact detected span boundaries, fixing the Ann/Anna problem
  • Tagged text: new _apply_tagged_text_replacements matches entity values only within their tag wrappers for all four notations (xml/bracket/paren/sentinel)
  • Error logging: raw PII values removed; reports count + entity labels only
  • _get_replace_pairs: now returns (original, synthetic, label) triples to support both fixes
  • Two new regression tests: substring guard (Ann/Anna) and full-coverage check; existing tests updated with COL_TAG_NOTATION

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.

2 participants