feat(detection): add regex entity detection - #265
lipikaramaswamy wants to merge 21 commits into
Conversation
Signed-off-by: lipikaramaswamy <lramaswamy@nvidia.com>
|
| ) | ||
| expanded = expand_entity_occurrences(text=text, entities=validated) | ||
| accepted_regex = _parse_entity_spans(row.get(COL_REGEX_ACCEPTED_ENTITIES, {})) | ||
| protected = merge_entity_sources(accepted_regex, validated) |
There was a problem hiding this comment.
When an overlapping built-in rule bypasses LLM validation while a higher-priority user rule takes the default LLM route, this final merge always prioritizes the accepted-route list. For identical spans with different labels, it therefore discards the validated user match in favor of the built-in match, violating the documented regex_user > regex_builtin > detector precedence and producing the wrong final label. Merge the routes using each candidate's source priority instead of argument order.
There was a problem hiding this comment.
Fixed in 46907f8. Validation routes are now recombined by candidate provenance, preserving regex_user > regex_builtin > other detector sources regardless of validate_with_llm. Regression tests cover both route permutations and the finalization merge.
| if rule.label == "url": | ||
| trimmed = text[start:end].rstrip(_URL_TRAILING_PUNCTUATION) | ||
| end = start + len(trimmed) |
There was a problem hiding this comment.
Blindly stripping every trailing closing delimiter truncates valid URLs containing balanced parentheses, such as https://en.wikipedia.org/wiki/Foo_(bar). The shortened value still passes URL validation and is emitted with an end offset before the legitimate ), so replacement operates on a malformed partial URL. Make punctuation trimming balance-aware rather than applying rstrip unconditionally.
There was a problem hiding this comment.
Fixed in 46907f8. URL suffix trimming is now delimiter-balance-aware: balanced delimiters remain part of the URL, while unmatched prose closers are removed. Tests cover nested ASCII delimiters, CJK pairs, unmatched closers, IPv6 host brackets, and exact offsets.
Signed-off-by: lipikaramaswamy <lramaswamy@nvidia.com>
|
/nvskills-ci |
Signed-off-by: nvskills-svc-account <svc-nvskills-signing@nvidia.com>
| rule_id="nemo.email.v1", | ||
| label="email", | ||
| pattern=( | ||
| r"(?<![A-Za-z0-9.!#$%&'*+/=?^_`{|}~-])" |
There was a problem hiding this comment.
Some valid international email domains are missed because these character classes exclude Unicode combining marks. I reproduced this with x@उदाहरण.भारत and decomposed x@éxample.com; both domains encode successfully with IDNA but produce no match here. Could we allow IDNA-valid marks, possibly after normalization, and add a non-CJK IDN test?
There was a problem hiding this comment.
Fixed in f0c3781. Domain labels now allow Unicode combining marks and are NFC-normalized before IDNA validation, while detected values and offsets remain unchanged. Added Devanagari and decomposed-Latin regression cases.
| ResolvedRegexRule( | ||
| rule_id="nemo.ipv6.v1", | ||
| label="ipv6", | ||
| pattern=r"(?<![0-9A-Fa-f:])(?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4}(?![0-9A-Fa-f:])", |
There was a problem hiding this comment.
IPv4-embedded IPv6 addresses get split unexpectedly here. For example, ::ffff:192.0.2.128 is detected as ::ffff:192, which IPv6Address accepts as a different valid address. With IPv4 detection enabled, the dotted portion may be replaced while ::ffff: remains visible. Could we extend the pattern to consume dotted IPv4 tails and add an exact-span test?
There was a problem hiding this comment.
Fixed in f0c3781. The IPv6 rule now consumes dotted IPv4 tails before considering the hexadecimal-only form. Added an exact value and offset regression test for ::ffff:192.0.2.128 with IPv4 detection enabled.
| compiled = regex.compile(value) | ||
| except regex.error as exc: | ||
| raise ValueError(f"Invalid regex pattern {value!r}: {exc}") from exc | ||
| match = compiled.search("") |
There was a problem hiding this comment.
I think contextual zero-width rules can slip through this check. Patterns such as (?=CASE) and (?<=A) construct successfully, but detection later skips all their zero-length matches, so the rule silently does nothing. It would be safer to reject these during configuration validation, with a couple of lookahead and lookbehind tests.
There was a problem hiding this comment.
Fixed in 52cdee3. RegexRule validation now rejects contextual zero-width matches, including the reported lookahead and lookbehind forms, while allowing lookarounds that consume a non-empty match. Runtime detection now fails explicitly with the rule ID and offset if an unprobed context-dependent zero-width match is encountered instead of silently skipping it. Tests cover both configuration cases, consuming lookarounds, and the runtime backstop.
There was a problem hiding this comment.
The reported lookahead and lookbehind cases look good now. There is still a similar gap for \K: RegexRule(label="case_id", pattern=r"CASE\K") passes validation, then produces a (4, 4) span and fails the row at runtime. Could the sequence-width calculation reset when it encounters core.Keep, with a test covering this form?
There was a problem hiding this comment.
Fixed in dd5bf2e. Sequence width analysis now resets accumulated width when it encounters regex._regex_core.Keep, so CASE\K is rejected during RegexRule configuration. The runtime zero-width guard remains in place, and the reported form is covered by the configuration regression test.
|
|
||
|
|
||
| def _validate_url(candidate: RegexCandidate) -> bool: | ||
| target = candidate.value if not candidate.value.startswith("www.") else f"https://{candidate.value}" |
There was a problem hiding this comment.
Small case-sensitivity mismatch here: the regex accepts WWW.example.com, but this prefix check only recognizes lowercase www.. That leaves the value without a scheme, and urlsplit rejects it. A case-insensitive check such as candidate.value.lower().startswith("www.") should cover it.
There was a problem hiding this comment.
Fixed in f0c3781. The www prefix check is now case-insensitive, with a regression test for WWW.example.com/path.
| @@ -1,85 +1,125 @@ | |||
| <!-- SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. --> | |||
| <!-- SPDX-License-Identifier: Apache-2.0 --> | |||
| # Skill Benchmark: anonymizer | |||
There was a problem hiding this comment.
The copyright check currently flags this file and skills/anonymizer/skill-card.md because they are missing the repository's SPDX headers. Adding the standard headers to both should clear the aggregate CI failure.
There was a problem hiding this comment.
Fixed in f0c3781. Added the repository standard SPDX headers to BENCHMARK.md and skill-card.md. The full copyright check now passes.
There was a problem hiding this comment.
f0c37811 added the headers, but the regeneration in 49ea1652 removed them again. The current Check job is failing on BENCHMARK.md and skill-card.md as a result. It looks like the generation step needs to preserve the SPDX headers when rewriting these files.
| [issue #262](https://github.com/NVIDIA-NeMo/Anonymizer/issues/262). This | ||
| document scopes the deterministic regex and validator feature as a focused | ||
| slice of the broader | ||
| [Multi-Pole Detection](../detection-poles/multi-pole-detection.md) design. |
There was a problem hiding this comment.
This link points to plans/detection-poles/multi-pole-detection.md, which isn't present in the PR. Since the broader framework is larger than this change, could we replace it with a follow-up issue or design doc? A short scope covering common detector output, configurable fan-in, and execution/failure contracts would make the intended direction clear without expanding this implementation.
|
|
||
| Merge policy: | ||
|
|
||
| 1. Reject malformed or out-of-bounds spans. |
There was a problem hiding this comment.
One edge case to consider before resolving overlaps here: the winning candidate hasn't necessarily been contextually validated yet. A longer candidate can remove an overlapping fallback, then get dropped by the LLM, leaving neither candidate. Coalescing only exact same-label/span matches before validation, while retaining all their origins, would let final overlap resolution happen after the decisions are known.
There was a problem hiding this comment.
Implemented in 6bd28e0. Pre-validation fan-in now coalesces only exact same-label/span duplicates, retains a deterministic origin chain, and preserves partial overlaps and same-span label alternatives as independently identified validation candidates. Final source-aware overlap resolution runs after keep/drop/reclass decisions. Regression coverage confirms that a shorter fallback survives when the longer candidate is dropped, while the longer candidate wins when both survive.
| ## Workflow Architecture | ||
|
|
||
| The current seed path parses GLiNER output directly into `COL_SEED_ENTITIES`. | ||
| Split candidate generation from seed fan-in: |
There was a problem hiding this comment.
This feels like the right extension seam. It may be worth noting that GLiNER and regex are the first two producers of a future common detector-output contract, with fan-in eventually accepting a configured set of producer columns. The current source-specific implementation can stay, but the note would help keep the next detector from adding another parallel set of constants and merge paths.
There was a problem hiding this comment.
Thanks, Andre. We decided to keep this plan focused on the implemented regex and GLiNER graph and removed the forward-looking multi-pole references, including the missing design-doc link. The current plan now documents the concrete two-producer contract: regex and GLiNER run as independent columns, COL_SEED_ENTITIES is their deterministic fan-in barrier, and final overlap resolution occurs after validation.
|
The overall direction makes sense, and regex plus GLiNER as independent Data Designer columns feels like a solid first step toward layered detection. I don't think this PR needs to build the full multi-detector framework. Keeping the public regex API and focusing here on correct two-source behavior seems like the right scope. Could we capture a follow-up issue or design for the general framework? I'd include a shared candidate/outcome shape with multiple origins, one independently schedulable column per detector, generic fan-in, overlap resolution after validation, and per-detector preparation, resource, timeout, and failure contracts. A useful acceptance test would be adding a dummy third detector without changing fan-in, while producing identical results regardless of completion order. That follow-up could also replace the currently missing multi-pole design referenced by this plan. |
Signed-off-by: lipikaramaswamy <lramaswamy@nvidia.com>
Signed-off-by: lipikaramaswamy <lramaswamy@nvidia.com>
| text=text, | ||
| ) | ||
| regex_entities = _parse_entity_spans(row.get(COL_REGEX_ENTITIES, {})) | ||
| entities = coalesce_exact_entity_candidates(regex_entities, detector_entities) |
There was a problem hiding this comment.
Overlapping Candidates Lose Tags
When regex and detector candidates overlap, this change preserves both in the validation candidate list. However, build_tagged_text skips any span that starts before the current cursor, so the tagged prompt omits some same-boundary or partially overlapping candidates even though they remain in the validation skeleton. The LLM may therefore make an incorrect keep, drop, or reclassification decision without seeing the corresponding tagged occurrence. The validation prompt needs a representation that shows every candidate unambiguously.
There was a problem hiding this comment.
Fixed in ce66d82 without adding crossing markers to the readable tagged text. Every validation manifest entry now carries its existing context_before and context_after fields into the prompt, and the validator instructions treat that manifest as authoritative when an overlapping candidate cannot be rendered inline. Regression coverage confirms tagged_text stays clean while both overlapping candidates retain independently identifiable context.
There was a problem hiding this comment.
Superseded the context-window approach in 6c8a877 to avoid per-candidate token duplication. Validation text now renders each connected overlap region exactly once as a neutral CANDIDATE_GROUP, and the prompt manifest adds only a compact group-id to candidate-id mapping. Non-overlapping entities retain their normal readable tags, and final overlap decisions remain independent. Tests cover nested/crossing spans, overlaps with deterministic accepted candidates, and overlap groups split across validation chunks.
Signed-off-by: lipikaramaswamy <lramaswamy@nvidia.com>
Signed-off-by: lipikaramaswamy <lramaswamy@nvidia.com>
Signed-off-by: lipikaramaswamy <lramaswamy@nvidia.com>
Signed-off-by: lipikaramaswamy <lramaswamy@nvidia.com>
Signed-off-by: lipikaramaswamy <lramaswamy@nvidia.com>
Signed-off-by: lipikaramaswamy <lramaswamy@nvidia.com>
Signed-off-by: lipikaramaswamy <lramaswamy@nvidia.com>
|
/nvskills-ci |
Signed-off-by: nvskills-svc-account <svc-nvskills-signing@nvidia.com>
There was a problem hiding this comment.
agent (review-pr): Request changes — correctness fixes and integration work remain
local human-driven agent review found a few more bits, in addition to the outstanding comments:
-
Medium: URL validation accepts malformed hosts.
regex_detection.py:_validate_url()accepts any IDNA-encodable host containing a dot, includinghttp://-bad.comandhttp://999.999.999.999/path, withvalidate_with_llm=False. These false positives reduce detection precision and can cause unnecessary replacement of text. Define the intended hostname policy, validate IP-shaped hosts withipaddress, and validate DNS-style labels before accepting them. -
Low: callable serialization succeeds but produces an unusable configuration.
RegexRuleserializes a callable asmodule:qualname, but restored configurations cannot resolve that representation. Direct callables are documented as in-process only; serialization should fail clearly at that boundary instead of emitting a value that later fails as an unknown validator. -
Previously reported:
CASE\Kstill permits a zero-width match.
Configuration accepts the pattern, then runtime matching produces(4, 4)and drops the row. Width analysis needs to account forcore.Keep. -
Previously reported: generated files still lack SPDX headers.
Signing regeneration removed headers fromBENCHMARK.mdandskill-card.md, and the copyright check still fails. Fix the generator before restoring the headers. The restored files will have different hashes, so regenerate and signskill.oms.sigafter their final contents are settled.
The PR also conflicts with current main across detection code, configuration, tests, interface code, and skill artifacts. This is an integration gate. Conflict resolution must preserve both regex detection and the newer entity-exclusion behavior.
Local validation at 49ea1652: 1341 passed; format and type checks passed; copyright check failed on the two generated Markdown files.
Prompts for managed agents
Agents 1–3 own the fixes below; Agent 4 owns final integration. Each agent should preserve other agents’ changes and report the files changed, validation results, and any unresolved issues.
Agent 1 — URL validation
Fix built-in URL host validation in src/anonymizer/engine/detection/regex_detection.py. Reproduce acceptance of http://-bad.com and http://999.999.999.999/path, and establish the current underscore-host behavior, using BuiltinRegex(label="url", validate_with_llm=False). Define the intended hostname policy, validate IP-shaped hosts with ipaddress, and validate DNS-style labels consistently with IDNA support. Preserve valid Unicode domains, bracketed IPv6 hosts, ports, balanced delimiters, and exact offsets. Add focused positive and negative tests. Keep changes scoped to URL validation.
Agent 2 — regex configuration boundaries
Fix two configuration issues without enabling arbitrary imports. First, reject patterns such as r"CASE\K" during RegexRule validation by accounting for regex._regex_core.Keep semantics; retain the runtime zero-width backstop. Second, make JSON serialization of direct callable validators fail clearly, preserving the documented rule that only registered entry-point names are portable. Add tests covering RegexRule and Detect JSON round trips with registered names, serialization failures for top-level callables, and \K validation. Preserve validator trust boundaries.
Agent 3 — generated skill artifacts
Identify why NVSkills regeneration removes repository SPDX headers from skills/anonymizer/BENCHMARK.md and skills/anonymizer/skill-card.md. Fix the producing template or generation step so future runs retain the headers, then regenerate both files. Renew skills/anonymizer/skill.oms.sig only after the final bytes are stable, and run the copyright and skill-package checks. Coordinate with Agent 4 so any artifact changes during integration are followed by regeneration and signing.
Agent 4 — rebase and integration
Integrate the fixes and rebase PR 265 onto current main, resolving conflicts semantically. Preserve both regex detection and the newer entity-exclusion/denylist behavior through AnonymizerConfig, interface plumbing, detection workflow columns, postprocessing, and tests. Regenerate signed skill artifacts after source conflicts are resolved, and coordinate final signing with Agent 3. Run make test, make format-check, make typecheck, and make copyright-check. Report any remaining conflicts or failed checks without discarding either feature to complete the merge.
Signed-off-by: lipikaramaswamy <lramaswamy@nvidia.com>
|
Addressed review items 1–3 in dd5bf2e:
Focused tests and the full 1,352-test suite pass; formatting, lint, and type checks pass. |
| return False | ||
| if len(host) > 253 or "." not in host: | ||
| return False | ||
| return all(_DNS_LABEL_RE.fullmatch(label) is not None for label in host.split(".")) |
There was a problem hiding this comment.
Valid fully qualified URLs can include an explicit DNS root dot, such as https://example.com./path. The candidate regex passes this URL to the local validator, and urlsplit preserves the trailing dot. Splitting that hostname creates an empty final component, which fails _DNS_LABEL_RE, so the URL is discarded before contextual validation and sensitive URLs can be missed. Permit the terminal root label while retaining the malformed-label checks.
Signed-off-by: lipikaramaswamy <lramaswamy@nvidia.com>
Signed-off-by: lipikaramaswamy <lramaswamy@nvidia.com>
Signed-off-by: lipikaramaswamy <lramaswamy@nvidia.com>
Signed-off-by: lipikaramaswamy <lramaswamy@nvidia.com>
…tion Signed-off-by: lipikaramaswamy <lramaswamy@nvidia.com>
|
Merge-resolution note on
Added integration coverage for excluded custom-regex labels and locally accepted regex matches at both the pre-augmentation and finalization boundaries. The merged suite passes locally (1,467 tests), and the Python 3.11/3.12/3.13 CI jobs pass. @memadi tagging you for visibility since you authored the excluded-labels change. The merge keeps its original early filtering behavior and extends the safety-net filtering to the new regex routes. |
|
/nvskills-ci |
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Signed-off-by: lipikaramaswamy <lramaswamy@nvidia.com>
|
/nvskills-ci |
Related Issue
Fixes #262.
Plan Document
Hybrid regex entity detection plan
Summary
Adds regex-based entity detection as a first-class detection source alongside GLiNER and LLM detection.
Detect.regex_rulesconfiguration surface for built-in customization and user-defined rules.Type of Change
Contributor Checklist
type(scope): description).Validation
TMPDIR=/tmp make test— 1312 passed, 1 warning.make format-check— passed.ty checkfor changed files — passed.make docs-buildfrom a clean worktree at this commit — passed.Documentation and Artifacts
make docs-buildpasses locally.