Skip to content

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

Closed
asteier2026 wants to merge 11 commits into
mainfrom
asteier2026/feature/rewrite-programmatic-prereplace
Closed

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

Conversation

@asteier2026

@asteier2026 asteier2026 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Update 8/13: I think PR #246 supersedes this PR now

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 direct-identifier replacement from the rewrite LLM to a deterministic pre-processing step. Before the LLM sees the text, _apply_direct_replacements substitutes all replace-method entities using span-aware (character offset) replacement for plain text and a single-pass tagged-span regex for tagged text, then writes the results to COL_PREREPLACE_TEXT / COL_PREREPLACE_TAGGED_TEXT. The replacement_map block is removed from the rewrite prompt, replace entities are excluded from COL_REWRITE_DISPOSITION_BLOCK, and the repair baseline switches from COL_TEXT to COL_PREREPLACE_TEXT.

  • Adds _apply_direct_replacements with a hard RuntimeError when any required replacement is unmatched, preventing unprotected PII from reaching the LLM.
  • 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.
  • Replaces _filter_replacement_map_for_prompt / COL_REPLACEMENT_MAP_FOR_PROMPT entirely; the repair step now uses COL_PREREPLACE_TEXT as its baseline.

Confidence Score: 5/5

  • The change is safe to merge. The core correctness guarantees — single-pass cascade prevention, hard failure on unmatched replacements, and correct repair baseline — are all working and tested.
  • All previously discussed issues (silent passthrough on missing map, cascade replacement, double disposition parse) have been resolved. Remaining observations are edge-case testing gaps and a log-clarity nit, none of which affect the correctness of the production pipeline.
  • The fallback regex branch in _apply_direct_replacements (lines 284-288 of rewrite_generation.py) is worth a second look before adding word-boundary-sensitive entity names to production workloads.

Important Files Changed

Filename Overview
src/anonymizer/engine/rewrite/rewrite_generation.py Core change: replaces _filter_replacement_map_for_prompt with _apply_direct_replacements. Uses span-aware + single-pass regex substitution. The fallback regex path (when COL_FINAL_ENTITIES has no valid spans) performs bare substring matching without word boundaries, which could corrupt partial matches like "Ann" inside "Anna" in production.
src/anonymizer/engine/replace/llm_replace_workflow.py Adds whitespace-normalized fallback matching in _filter_replacement_map_to_input_entities; correctly rewrites the replacement entry to the canonical entity value so downstream lookups succeed.
src/anonymizer/engine/rewrite/repair.py Correctly switches from COL_TEXT to COL_PREREPLACE_TEXT as the repair baseline, so the LLM sees pre-replaced text (synthetics already substituted) rather than the original PII-containing text.
tests/engine/test_rewrite_generation.py Good coverage of replacement, error cases, and single-pass cascade prevention. The cascade test (test_apply_direct_replacements_no_cascade_when_synthetic_matches_another_original) omits COL_FINAL_ENTITIES, so it exercises the regex fallback path rather than the production span-aware _apply_replacement_map_to_text path.
src/anonymizer/engine/constants.py Straightforward constant rename: COL_REPLACEMENT_MAP_FOR_PROMPT replaced by COL_PREREPLACE_TEXT and COL_PREREPLACE_TAGGED_TEXT.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Row with COL_TEXT and COL_TAGGED_TEXT] --> B[_format_rewrite_disposition_block]
    B --> C{protection_method?}
    C -->|replace| D[Excluded from REWRITE_DISPOSITION_BLOCK]
    C -->|generalize or remove| E[Included in REWRITE_DISPOSITION_BLOCK]

    A --> F[_apply_direct_replacements]
    F --> G[_get_replace_pairs]
    G --> H{raw_map present?}
    H -->|No| I[pairs is empty, replace_values filled]
    H -->|Yes| J[Match map entries with whitespace fallback]
    J --> K[pairs and replace_values returned]
    I --> L{unmatched entries?}
    K --> L
    L -->|yes| M[raise RuntimeError - PII protection refused]
    L -->|no| N{pairs non-empty?}
    N -->|No| O[Pass text through unchanged]
    N -->|Yes| P{COL_FINAL_ENTITIES has valid spans?}
    P -->|Yes| Q[_apply_replacement_map_to_text - span-aware]
    P -->|No| R[Fallback - single-pass regex without word boundaries]
    Q --> S[COL_PREREPLACE_TEXT]
    R --> S
    A --> T[_apply_tagged_text_replacements - single-pass regex on full tagged spans]
    T --> U[COL_PREREPLACE_TAGGED_TEXT]

    S --> V[LLM Rewrite - replacement_map block removed]
    U --> V
    E --> V
    V --> W[COL_REWRITTEN_TEXT]
    S --> X[Repair Prompt - baseline is COL_PREREPLACE_TEXT]
    W --> X
    X --> Y[COL_REWRITTEN_TEXT_NEXT]
Loading

Reviews (8): Last reviewed commit: "style: apply ruff format to rewrite_gene..." | 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
…matched

_get_replace_pairs now returns (pairs, replace_values) so the caller can
detect gaps. _apply_direct_replacements raises RuntimeError when any
required entity has no replacement entry, covering both the missing-map
and partial-map cases. Silently passing PII-containing text to the rewrite
LLM is unsafe because replace entities are excluded from the disposition
block and would receive no protection instructions.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
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.

…e error logging

Three reviewer-requested changes:
- Plain text: reuses _apply_replacement_map_to_text (span-aware via character
  offsets from COL_FINAL_ENTITIES) so 'Ann' cannot corrupt 'Anna'
- Tagged text: new _apply_tagged_text_replacements replaces only within tag
  wrappers per notation (xml/bracket/paren/sentinel), preventing substring
  contamination without needing remapped offsets
- Error message: omits raw entity values; reports count and entity labels only
  (_get_replace_pairs now returns (original, synthetic, label) triples)
- Tests: regression test for Ann/Anna substring guard, coverage check that all
  replace spans transform, updated existing tests with COL_TAG_NOTATION

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@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

Comment on lines +202 to +239
def _apply_tagged_text_replacements(
tagged_text: str, pairs: list[tuple[str, str, str]], tag_notation: str
) -> str:
"""Replace entity values in tagged text using tag-boundary-aware matching.

Matches each entity value only when it appears as the text content of its
corresponding tag wrapper, preventing substring corruption (e.g. 'Ann' inside
'Anna' is safe because the tagged form '<first_name>Ann</first_name>' is bounded
by tag delimiters that 'Anna' does not share).
"""
for original, synthetic, label in sorted(pairs, key=lambda p: len(p[0]), reverse=True):
esc_o = re.escape(original)
esc_l = re.escape(label)
if tag_notation == "xml":
tagged_text = re.sub(
r"(<" + esc_l + r">)" + esc_o + r"(</" + esc_l + r">)",
lambda m, s=synthetic: m.group(1) + s + m.group(2),
tagged_text,
)
elif tag_notation == "bracket":
tagged_text = re.sub(
r"\[\[" + esc_o + r"\|" + esc_l + r"\]\]",
lambda m, s=synthetic, l=label: "[[" + s + "|" + l + "]]",
tagged_text,
)
elif tag_notation == "paren":
tagged_text = re.sub(
r"\(\(SENSITIVE:" + esc_l + r"\|" + esc_o + r"\)\)",
lambda m, s=synthetic, l=label: "((SENSITIVE:" + l + "|" + s + "))",
tagged_text,
)
else: # sentinel
tagged_text = re.sub(
r"(<<SENSITIVE:" + esc_l + r">>)" + esc_o + r"(<</SENSITIVE:" + esc_l + r">>)",
lambda m, s=synthetic: m.group(1) + s + m.group(2),
tagged_text,
)
return 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 Sequential loop in _apply_tagged_text_replacements can still cascade

The function iterates over pairs sequentially, so a synthetic value that matches another entity's original is re-replaced in a later iteration. For example, with Alice → "Bob" and Bob → "Carlos", iteration 1 rewrites [[Alice|first_name]] to [[Bob|first_name]], and iteration 2 then matches the freshly written [[Bob|first_name]] and replaces it with [[Carlos|first_name]] — Alice ends up as Carlos in COL_PREREPLACE_TAGGED_TEXT while plain text correctly has "Bob". The LLM then sees wrong synthetic values in the tagged text it is asked to rewrite.

The existing cascade test (test_apply_direct_replacements_no_cascade_when_synthetic_matches_another_original) passes silently because COL_TAGGED_TEXT is set to "Alice and Bob met." (plain, untagged text). The xml-mode regex (<first_name>)Alice(</first_name>) finds no matches in that string, so _apply_tagged_text_replacements makes no substitutions and the cascade is never exercised. There is also no assertion on COL_PREREPLACE_TAGGED_TEXT in that test.

The plain-text path uses a single-pass regex (re.compile("|".join(...)).sub(...)) to avoid this exact problem. The tagged-text path needs the same treatment — build a single combined regex per notation format that matches all tagged originals simultaneously, then look up the replacement in one pass.

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.

Just fixed this too

Sequential per-pair regex substitution in _apply_tagged_text_replacements
allowed cascade (Alice→Bob then Bob→Carlos in the same tagged text). Rewrite
as a single-pass lookup substitution matching full tagged spans, mirroring
the plain-text path. Updates the cascade test to use real tagged text and
assert on COL_PREREPLACE_TAGGED_TEXT.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…kflow.py

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@lipikaramaswamy

Copy link
Copy Markdown
Collaborator

Closing this PR in favor of #246

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