Add Tamil NER routing and WPF test harness for POC validation.

Introduce dual-script ONNX NER routing (English/Tamil/mixed), Tamil console samples and integration tests, model download scripts, and a resizable WPF MVVM harness with click-to-load prompts, batch validation, and runtime-adjustable detection panels.
This commit is contained in:
Bilal Nazer Ali
2026-07-07 17:12:38 +05:30
parent a707c6c9cf
commit cf8f5a7232
71 changed files with 4494 additions and 356 deletions

4
.gitignore vendored
View File

@@ -10,7 +10,11 @@ models/*.onnx
models/vocab.txt
models/ner-labels.txt
models/*.json
models/en/*
models/ta/*
!models/.gitkeep
!models/en/.gitkeep
!models/ta/.gitkeep
## IDE
.idea/

View File

@@ -3,6 +3,7 @@
<Project Path="src/PiiRedaction.ConsoleApp/PiiRedaction.ConsoleApp.csproj" />
<Project Path="src/PiiRedaction.Core/PiiRedaction.Core.csproj" />
<Project Path="src/PiiRedaction.Infrastructure/PiiRedaction.Infrastructure.csproj" />
<Project Path="src/PiiRedaction.TestHarness.Wpf/PiiRedaction.TestHarness.Wpf.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/PiiRedaction.Core.Tests/PiiRedaction.Core.Tests.csproj" />

View File

@@ -15,6 +15,10 @@ Financial and customer-service prompts often contain regulated data (names, gove
For solution design, data-flow diagrams, trust boundaries, and project responsibilities, see **[docs/architecture.md](docs/architecture.md)**.
For English and Tamil ONNX NER model IDs, assets, routing, and reproduction steps, see **[docs/ner-models.md](docs/ner-models.md)**.
**Planned:** Tamil / Tanglish person-name support via dual ONNX NER routing — see **[docs/tamil-tanglish-ner-plan.md](docs/tamil-tanglish-ner-plan.md)**.
## Why Three Detection Strategies?
| Strategy | Used For | Rationale |
@@ -31,15 +35,17 @@ The placeholder map (`<PERSON_1>` → original value) is kept **in-process** for
```
src/
├── PiiRedaction.ConsoleApp/ # Presentation: input/output, DI bootstrap
├── PiiRedaction.Core/ # Business logic: detection, redaction, models
── PiiRedaction.Infrastructure/ # Technical adapters: ONNX Runtime, mock LLM
models/ # Optional ONNX model files (gitignored)
├── PiiRedaction.ConsoleApp/ # Console demo: input/output, DI bootstrap
├── PiiRedaction.TestHarness.Wpf/ # WPF MVVM test harness for manual POC validation
── PiiRedaction.Core/ # Business logic: detection, redaction, models
└── PiiRedaction.Infrastructure/ # Technical adapters: ONNX Runtime, mock LLM
models/ # Optional ONNX model files (gitignored)
```
| Project | Responsibility |
|---------|----------------|
| `PiiRedaction.ConsoleApp` | Read prompt, call sanitizer, display results, call LLM service |
| `PiiRedaction.TestHarness.Wpf` | Desktop test harness: preset prompts, redact UI, batch validation |
| `PiiRedaction.Core` | PII detection abstractions, redaction, sanitization orchestration |
| `PiiRedaction.Infrastructure` | ONNX model runner, `IChatClient` mock implementation |
@@ -63,7 +69,7 @@ dotnet build
dotnet run --project src/PiiRedaction.ConsoleApp
```
By default the console app runs **11 curated sample prompts** covering NER/person names, regex identifiers, domain IDs, combined scenarios, and a clean no-PII ticket.
By default the console app runs **16 curated sample prompts** covering English and Tamil/Tanglish/mixed person names, regex identifiers, domain IDs, combined scenarios, and a clean no-PII ticket. No flags are required for Tamil samples — they run in the default batch alongside English.
List available samples:
@@ -78,6 +84,27 @@ dotnet run --project src/PiiRedaction.ConsoleApp -- --sample 2
dotnet run --project src/PiiRedaction.ConsoleApp -- --name MrTitlePerson
```
### WPF Test Harness
A desktop **MVVM** application for interactive POC validation with English and Tamil prompts. Requires **Windows** (`net10.0-windows`).
**Prerequisites:** English and Tamil ONNX models downloaded (see [ONNX Model Setup](#onnx-model-setup)).
```bash
dotnet run --project src/PiiRedaction.TestHarness.Wpf
```
**Workflow:**
1. **Select a test prompt** from the left panel (grouped by language: English, Tamil, Mixed, Tanglish) or type your own prompt in the input box.
2. **Click a test prompt** in the left panel to load it into the input box (previous results are cleared automatically).
3. Click **Redact** to run the full detection pipeline. The status bar shows model availability, script composition (LatinOnly / TamilOnly / Mixed), and elapsed time.
4. Review **Sanitized Output**, detected entities, and the placeholder map in the right panel. A leak warning appears if any detected value remains in the sanitized text.
5. Optionally click **Send Mock LLM** to send only the sanitized prompt to the mock LLM.
6. Click **Run All** to execute all **22 curated scenarios** (16 console samples + 6 harness-only edge cases) and view pass/fail results in the batch panel.
The harness uses the same DI registrations and `IPromptSanitizer` pipeline as the console app, with thin application services (`IRedactionAppService`, `ITestPromptCatalog`, `IScriptAnalysisService`, `IModelStatusService`) following SOLID principles.
Interactive mode (enter your own prompt):
```bash
@@ -100,7 +127,12 @@ Samples are defined in [`SamplePromptCatalog.cs`](src/PiiRedaction.ConsoleApp/Sa
| 7 | PersonWithEmailNoPhone | NER + Regex | `Customer Arjun Mehta` + email |
| 8 | AllRegexTypes | Regex | email, phone, PAN, Aadhaar, card |
| 9 | AllDomainIds | Domain | LN, CID, ACC |
| 10 | NoPiiCleanTicket | Negative | no redaction |
| 10 | TamilCustomerNameOnly | NER (Tamil) | `வாடிக்கையாளர் ராஜேஷ் குமார்` |
| 11 | TamilWithPhonePan | NER (Tamil) + Regex | Tamil person + phone + PAN |
| 12 | TanglishCustomer | NER (English/Tanglish) | `Customer Senthil` + phone |
| 13 | MixedTamilEnglish | NER (Mixed) | `வாடிக்கையாளர் Ravi Kumar` + phone |
| 14 | TamilFullFinancial | NER (Tamil) + Regex + Domain | Tamil canonical demo |
| 15 | NoPiiCleanTicket | Negative | no redaction |
Person names are detected via **ONNX NER** using `dslim/bert-base-NER` (or a compatible token-classification export). A real model is **required** for person-name detection; there is no regex or heuristic fallback.
@@ -147,36 +179,41 @@ Person-name detection requires a token-classification ONNX model and companion t
| File | Purpose |
|------|---------|
| `models/ner-model.onnx` | Exported NER model |
| `models/vocab.txt` | BERT WordPiece vocabulary |
| `models/ner-labels.txt` | One BIO label per line (`O`, `B-PER`, `I-PER`, etc.) |
| `models/en/ner-model.onnx` | English BERT NER model (or legacy `models/ner-model.onnx`) |
| `models/en/vocab.txt` | BERT WordPiece vocabulary |
| `models/en/ner-labels.txt` | One BIO label per line (`O`, `B-PER`, `I-PER`, etc.) |
| `models/ta/model.onnx` | Tamil IndicBERT NER model |
| `models/ta/sentencepiece.bpe.model` | SentencePiece tokenizer for Tamil model |
| `models/ta/ner-labels.txt` | Fine-grained Tamil NER labels |
### Download script
### Download scripts
From the repository root:
```powershell
.\scripts\download-ner-model.ps1
.\scripts\download-tamil-ner-model.ps1
```
Or with Python directly:
```bash
python scripts/download-ner-model.py
python scripts/download-tamil-ner-model.py
```
The script exports [`dslim/bert-base-NER`](https://huggingface.co/dslim/bert-base-NER) via Hugging Face Optimum when Python is available. Otherwise it downloads the pre-exported ONNX assets from Hugging Face directly.
The English script exports [`dslim/bert-base-NER`](https://huggingface.co/dslim/bert-base-NER) via Hugging Face Optimum when Python is available. The Tamil script exports [`prachuryyaIITG/SampurNER_Tamil_IndicBERTv2`](https://huggingface.co/prachuryyaIITG/SampurNER_Tamil_IndicBERTv2). Otherwise each script downloads pre-exported ONNX assets from Hugging Face directly.
Set `EnableTamilNer` to `false` in `appsettings.json` to revert to English-only routing.
### Inference pipeline
`OnnxNerModelRunner` performs the full pipeline:
`RoutingOnnxNerModelRunner` classifies script composition and delegates to:
- BERT WordPiece tokenization (`Microsoft.ML.Tokenizers`)
- ONNX Runtime inference (`input_ids`, `attention_mask`, optional `token_type_ids`)
- BIO label decoding (`B-PER` / `I-PER``PiiEntityType.Person`)
- Character-span alignment back to the source text
- **`EnglishOnnxNerRunner`** — BERT WordPiece tokenization for Latin script and Tanglish
- **`TamilOnnxNerRunner`** — SentencePiece tokenization for Tamil script (U+0B80U+0BFF)
When the model or tokenizer files are missing, person detection returns no results.
Both runners share `OnnxTokenClassifierRunner` for ONNX Runtime inference and BIO label decoding. Overlapping person spans from mixed-script prompts are merged (longer span wins).
## Swapping Mock LLM for Azure OpenAI
@@ -251,24 +288,29 @@ The solution includes an **NUnit** test suite across two projects:
dotnet test
dotnet test --filter "FullyQualifiedName~GoldenPromptTests"
dotnet test --filter "Category=RealModel"
dotnet test --filter "Category=TamilNer"
dotnet test --logger "console;verbosity=detailed"
```
Fast CI runs without the ONNX model: fake-based tests always execute; tests marked **`Category=RealModel`** are skipped when `models/ner-model.onnx` is absent. Download the model first:
Fast CI runs without the ONNX model: fake-based tests always execute; tests marked **`Category=RealModel`** or **`Category=TamilNer`** are skipped when the corresponding ONNX models are absent. Download models first:
```powershell
.\scripts\download-ner-model.ps1
.\scripts\download-tamil-ner-model.ps1
```
### Test architecture
- **`PromptScenarioCatalog`** — five focused end-to-end scenarios (canonical demo, multi-regex, duplicate people, overlap stress, no-PII negative)
- **`ProductionPipelineFactory`** — builds the same Domain → Regex → OnnxNer composite stack as production DI; `CreateWithRealModel(runner)` wires a real `OnnxNerModelRunner`
- **`PromptScenarioCatalog`** — five focused end-to-end English scenarios (canonical demo, multi-regex, duplicate people, overlap stress, no-PII negative)
- **`TamilPromptScenarioCatalog`** — five Tamil/Tanglish/mixed golden scenarios (fake NER for person spans)
- **`ProductionPipelineFactory`** — builds the same Domain → Regex → OnnxNer composite stack as production DI; `CreateWithRealModel(runner)` wires a real runner; `CreateWithRoutingRealModels` wires English + Tamil routing
- **`FakeOnnxNerModelRunner`** — unit-test double for NER; golden tests inject person spans per scenario
- **`GoldenPromptTests`** — end-to-end sanitization proof across the catalog (fake NER)
- **`RealNerModelFixture`** — shared fixture that loads `models/ner-model.onnx` once per class; skips when model missing
- **`RealNerModelRunnerTests`** — direct ONNX inference with span accuracy checks
- **`RealNerPipelineTests`** — full pipeline with real NER (canonical, multi-person, clean-ticket negative)
- **`RealNerPipelineTests`** — full pipeline with real English NER (canonical, multi-person, clean-ticket negative)
- **`RealTamilPipelineTests`** — full pipeline with routed English + Tamil NER (`Category=TamilNer`)
- **`RealTamilNerModelRunnerTests`** — direct Tamil ONNX inference (`Category=TamilNer`)
- **`OnnxNerModelRunnerTests`** — unit tests for missing/invalid model paths (no download required)
- **`CompositePiiDetectorTests`** — overlap merge and source-priority rules
- **`LlmBoundaryTests`** — verifies raw PII never appears in outbound LLM messages

View File

@@ -10,7 +10,7 @@ The POC validates a compliance-oriented pattern suitable for financial and custo
## Canonical Example
The console application ships with a **sample catalog** (11 prompts). The canonical demo is sample `FullFinancialWithCustomer`. The table below shows the exact strings produced by the production pipeline when the ONNX NER model is loaded (run `scripts/download-ner-model.ps1` first).
The console application ships with a **sample catalog** (16 prompts). The canonical demo is sample `FullFinancialWithCustomer`. Tamil script, Tanglish, and mixed-script samples run in the **default** `dotnet run` batch (no `--interactive` required). The table below shows the exact strings produced by the production pipeline when the ONNX NER models are loaded (run `scripts/download-ner-model.ps1` and `scripts/download-tamil-ner-model.ps1` first).
| Stage | Value |
|-------|-------|
@@ -38,7 +38,7 @@ Running `dotnet run --project src/PiiRedaction.ConsoleApp` executes all samples
### NER / person-name samples
These prompts exercise `OnnxNerPiiDetector` and `OnnxNerModelRunner`. Person names require the ONNX model (`models/ner-model.onnx` plus `vocab.txt` and `ner-labels.txt`). Without the model, person spans are not detected.
These prompts exercise `OnnxNerPiiDetector` and `RoutingOnnxNerModelRunner`. Person names require ONNX models (`models/en/` for English, `models/ta/` for Tamil script). Without models, person spans are not detected. Legacy `models/ner-model.onnx` is still supported for English.
| Sample | Input (excerpt) | Detected person | Sanitized (excerpt) |
|--------|-----------------|-----------------|---------------------|
@@ -50,6 +50,18 @@ These prompts exercise `OnnxNerPiiDetector` and `OnnxNerModelRunner`. Person nam
| **PersonWithDomainIds** | Customer Meera Iyer holds CID-7070… | Meera Iyer | Customer `<PERSON_1>` holds `<CUSTOMER_ID_1>`… |
| **PersonWithEmailNoPhone** | Customer Arjun Mehta wrote from arjun.mehta@company.in… | Arjun Mehta | Customer `<PERSON_1>` wrote from `<EMAIL_1>`… |
### Tamil / Tanglish / mixed samples
These prompts exercise `RoutingOnnxNerModelRunner` script routing. Tamil script uses `models/ta/`; Latin Tanglish uses `models/en/`. Mixed prompts may invoke both models.
| Sample | Input (excerpt) | Detected person | Sanitized (excerpt) |
|--------|-----------------|-----------------|---------------------|
| **TamilCustomerNameOnly** | வாடிக்கையாளர் ராஜேஷ் குமார் சேமிப்பு… | ராஜேஷ் குமார் | வாடிக்கையாளர் `<PERSON_1>` சேமிப்பு… |
| **TamilWithPhonePan** | வாடிக்கையாளர் ராஜேஷ் குமார் தொலைபேசி 9876543210 PAN… | ராஜேஷ் குமார் | `<PERSON_1>``<PHONE_1>``<PAN_1>` |
| **TanglishCustomer** | Customer Senthil phone 9876543210… | Senthil | Customer `<PERSON_1>` phone `<PHONE_1>`… |
| **MixedTamilEnglish** | வாடிக்கையாளர் Ravi Kumar phone 9876543210… | Ravi Kumar | வாடிக்கையாளர் `<PERSON_1>` phone `<PHONE_1>`… |
| **TamilFullFinancial** | வாடிக்கையாளர் ராஜேஷ் குமார் மின்னஞ்சல் ravi.kumar@gmail.com… | ராஜேஷ் குமார் | Tamil canonical — all placeholder types |
### Other sample categories
| Category | Sample | Purpose |
@@ -84,7 +96,9 @@ flowchart TB
end
subgraph infra [PiiRedaction.Infrastructure]
onnxRunner["OnnxNerModelRunner"]
onnxRunner["RoutingOnnxNerModelRunner"]
enRunner["EnglishOnnxNerRunner"]
taRunner["TamilOnnxNerRunner"]
mockLlm["MockLlmPromptService"]
mockChat["MockChatClient"]
end
@@ -110,6 +124,8 @@ flowchart TB
di -.-> mockLlm
```
`RoutingOnnxNerModelRunner` selects English and/or Tamil ONNX models based on script composition in the prompt. See [Dual-Model NER Routing (Tamil + English)](#dual-model-ner-routing-tamil--english) for the routing decision tree.
---
## Detection to Redaction Detail
@@ -123,7 +139,7 @@ flowchart LR
subgraph detectPhase [Detection Phase]
domainDet["DomainRulePiiDetector"]
regexDet["RegexPiiDetector"]
onnxDet["OnnxNerPiiDetector"]
onnxDet["OnnxNerPiiDetector<br/>(RoutingOnnxNerModelRunner)"]
composite["CompositePiiDetector"]
merge["Overlap merge and source priority"]
entityList["PiiEntity list"]
@@ -157,14 +173,144 @@ flowchart LR
2. On overlapping spans, the first registered detector wins.
3. Tie-breaking uses source priority: Domain (3) > Regex (2) > NER (1).
The ONNX NER detector delegates to `RoutingOnnxNerModelRunner`, which routes inference to English and/or Tamil models by script composition. See [Dual-Model NER Routing (Tamil + English)](#dual-model-ner-routing-tamil--english).
**Placeholder assignment** (applied by `PlaceholderPiiRedactor`):
- Format: `<{TYPE}_{n}>` (e.g. `<EMAIL_1>`, `<PERSON_1>`).
- Duplicate values of the same type reuse the same placeholder.
- Replacement proceeds from highest `StartIndex` to lowest to avoid index drift.
---
## Dual-Model NER Routing (Tamil + English)
Person-name detection uses two ONNX token-classifier models: **English** (`models/en/`, BERT WordPiece) and **Tamil** (`models/ta/`, SentencePiece or WordPiece). `OnnxNerPiiDetector` calls `RoutingOnnxNerModelRunner`, which classifies prompt script via `ScriptRouter` and dispatches to `EnglishOnnxNerRunner` and/or `TamilOnnxNerRunner`. Both runners share `OnnxTokenClassifierRunner` for BIO decoding; only **PERSON** spans are emitted.
The diagram below expands the detection and NER branches summarized in [High-Level Data Flow](#high-level-data-flow) and [Detection to Redaction Detail](#detection-to-redaction-detail).
### End-to-end pipeline (with NER branch)
```mermaid
flowchart TB
subgraph Entry["Console entry"]
A["Program.cs<br/>Host + AddPiiRedactionServices()"]
B["PromptDemoRunner.RunAsync()"]
A --> B
end
B --> C["SanitizationRequest(OriginalPrompt)"]
C --> D["PromptSanitizer.Sanitize()"]
subgraph Detect["CompositePiiDetector.Detect() — registration order"]
direction TB
E1["DomainRulePiiDetector<br/>LOAN_NUMBER, CUSTOMER_ID, ACCOUNT_NUMBER"]
E2["RegexPiiDetector<br/>EMAIL, PHONE, AADHAAR, PAN, CREDIT_CARD"]
E3["OnnxNerPiiDetector<br/>PERSON (via IOnnxNerModelRunner)"]
E1 --> MERGE
E2 --> MERGE
E3 --> MERGE
MERGE["Merge overlapping spans<br/>sort: StartIndex ↑, Length ↓, Source priority ↓<br/>(Domain=3, Regex=2, Ner=1)<br/>first candidate wins on overlap"]
end
D --> Detect
MERGE --> F["IReadOnlyList&lt;PiiEntity&gt;"]
F --> G["PlaceholderPiiRedactor.Redact()<br/>replace spans right-to-left<br/>dedupe by Type|Value → &lt;TYPE_n&gt;"]
G --> H["SanitizationResult<br/>SanitizedPrompt, DetectedEntities, PlaceholderMap"]
H --> I["MockLlmPromptService.SendPromptAsync(SanitizedPrompt)"]
I --> J["Mock LLM response<br/>(sanitized text only)"]
subgraph NerBranch["OnnxNerPiiDetector branch"]
E3 --> N1{"RoutingOnnxNerModelRunner<br/>.IsModelAvailable?"}
N1 -->|no| N2["return []"]
N1 -->|yes| N3["RoutingOnnxNerModelRunner<br/>.PredictEntities()"]
end
```
### RoutingOnnxNerModelRunner decision tree
`ScriptRouter.GetComposition` scans each character once. Tamil letters (U+0B80U+0BFF) and ASCII Latin letters (`char.IsAsciiLetter`) determine the route. When both scripts appear, classification is **Mixed** (early exit).
```mermaid
flowchart TB
IN["text"] --> SR["ScriptRouter.GetComposition(text)<br/>scan each char"]
SR --> C1{"LatinOnly?"}
SR --> C2{"TamilOnly?"}
SR --> C3{"Mixed?"}
SR --> C4{"NoLetters?"}
C1 -->|yes| EN1{"EnglishOnnxNerRunner<br/>.IsModelAvailable?"}
EN1 -->|yes| EN_RUN["EnglishOnnxNerRunner.PredictEntities(text)"]
EN1 -->|no| SKIP1["skip English"]
EN_RUN --> ACC
SKIP1 --> ACC
C2 -->|yes| TA_GATE{"EnableTamilNer<br/>&& TamilOnnxNerRunner<br/>.IsModelAvailable?"}
TA_GATE -->|yes| TA_RUN["TamilOnnxNerRunner.PredictEntities(text)"]
TA_GATE -->|no| SKIP2["skip Tamil"]
TA_RUN --> ACC
SKIP2 --> ACC
C3 -->|yes| EN2{"English available?"}
EN2 -->|yes| EN_MIX["EnglishOnnxNerRunner.PredictEntities(text)"]
EN2 -->|no| SKIP3["skip English"]
EN_MIX --> TA_GATE2{"EnableTamilNer<br/>&& Tamil available?"}
SKIP3 --> TA_GATE2
TA_GATE2 -->|yes| TA_MIX["TamilOnnxNerRunner.PredictEntities(text)"]
TA_GATE2 -->|no| SKIP4["skip Tamil"]
TA_MIX --> ACC
SKIP4 --> ACC
C4 -->|yes| EMPTY["no NER inference"]
EMPTY --> OUT_EMPTY["return []"]
subgraph EN_Pipeline["EnglishOnnxNerRunner"]
EN_RUN --> EN_ENC["BertWordPieceEncoder<br/>(model dir vocab.txt)"]
EN_ENC --> EN_OCR["OnnxTokenClassifierRunner<br/>NerLabelConfig.English<br/>B-PER / I-PER / B-PERSON / I-PERSON"]
end
subgraph TA_Pipeline["TamilOnnxNerRunner"]
TA_RUN --> TA_ENC["TokenClassifierEncoderFactory.Create()<br/>vocab.txt → BertWordPieceEncoder<br/>else SentencePiece (*.bpe.model, spiece.model, tokenizer.model)"]
TA_ENC --> TA_OCR["OnnxTokenClassifierRunner<br/>NerLabelConfig.Tamil<br/>label contains 'person' (case-insensitive)"]
end
subgraph SharedInference["OnnxTokenClassifierRunner (shared)"]
ENC["Encode(text, max 128 tokens)"]
ONNX["ONNX InferenceSession.Run<br/>input_ids + attention_mask [+ token_type_ids]"]
ARGMAX["Per-token argmax over logits"]
BIO["BIO decode → PiiEntityType.Person<br/>PiiDetectionSource.Ner"]
ENC --> ONNX --> ARGMAX --> BIO
end
EN_OCR --> SharedInference
TA_OCR --> SharedInference
BIO --> ACC["accumulate entities"]
ACC --> MERGE["MergePersonSpans()<br/>sort: Length ↓, StartIndex ↑<br/>drop overlapping spans<br/>(longer span wins)"]
MERGE --> OUT["return merged PERSON entities"]
```
### Routing rules
| Rule | Source | Behavior |
|------|--------|----------|
| **Script classification** | `ScriptRouter.GetComposition` | Single pass over characters. Tamil letter = U+0B80U+0BFF. Latin letter = `char.IsAsciiLetter`. Both seen → `Mixed` (early exit). Neither → `NoLetters`. Tamil only → `TamilOnly`. Latin only → `LatinOnly`. |
| **LatinOnly** | `RoutingOnnxNerModelRunner` | Run **English only** if `englishRunner.IsModelAvailable`. |
| **TamilOnly** | `RoutingOnnxNerModelRunner` | Run **Tamil only** if `EnableTamilNer` (default `true` in `PiiRedactionOptions`) **and** `tamilRunner.IsModelAvailable`. |
| **Mixed** | `RoutingOnnxNerModelRunner` | Run **both** models independently on the **full text** (English if available; Tamil if `EnableTamilNer` and available). |
| **NoLetters** | `RoutingOnnxNerModelRunner` | No NER inference; returns `[]` from routing (before merge). |
| **Model availability gate** | `OnnxNerPiiDetector` | If `RoutingOnnxNerModelRunner.IsModelAvailable` is false, NER detector returns `[]` (English OR Tamil available when Tamil enabled). |
| **Post-route merge** | `MergePersonSpans` | After EN/TA results are concatenated, overlapping PERSON spans are deduped; **longer span wins**, then ordered by `StartIndex`. |
| **Composite merge** | `CompositePiiDetector` | Domain → Regex → NER all run. Overlaps resolved globally: earlier registration order + longer span + higher source priority (Domain > Regex > Ner). |
| **Encoder choice** | `EnglishOnnxNerRunner` vs `TamilOnnxNerRunner` | English always uses `BertWordPieceEncoder`. Tamil uses factory: `vocab.txt` → WordPiece; else first SentencePiece file found; fallback WordPiece with warning. |
| **NER output scope** | `OnnxTokenClassifierRunner` | Only **PERSON** entities decoded from BIO tags; max sequence length **128** tokens. |
---
## Runtime Sequence
```mermaid
@@ -255,7 +401,7 @@ In the POC, `MockChatClient` simulates the external provider without network I/O
|---------|-------|----------------|
| `PiiRedaction.ConsoleApp` | Presentation | Application entry point; reads prompt (sample or interactive); bootstraps `IHost` and DI via `AddPiiRedactionServices`; orchestrates sanitization and LLM invocation; renders audit output (detected entities, sanitized text, placeholder map). |
| `PiiRedaction.Core` | Domain / Application | Defines abstractions (`IPiiDetector`, `IPiiRedactor`, `IPromptSanitizer`, `ILlmPromptService`); implements detection strategies (`RegexPiiDetector`, `DomainRulePiiDetector`, `OnnxNerPiiDetector`, `CompositePiiDetector`); implements `PlaceholderPiiRedactor` and `PromptSanitizer`; owns domain models (`PiiEntity`, `SanitizationResult`, `RedactionResult`) and configuration (`PiiRedactionOptions`). Has no dependency on ONNX Runtime or LLM SDKs. |
| `PiiRedaction.Infrastructure` | Infrastructure | Implements technical adapters: `OnnxNerModelRunner` (ONNX Runtime inference), `MockChatClient` and `MockLlmPromptService` (`Microsoft.Extensions.AI`); depends on Core abstractions and is swappable without changing domain logic. |
| `PiiRedaction.Infrastructure` | Infrastructure | Implements technical adapters: `RoutingOnnxNerModelRunner`, `EnglishOnnxNerRunner`, `TamilOnnxNerRunner` (ONNX Runtime inference), `MockChatClient` and `MockLlmPromptService` (`Microsoft.Extensions.AI`); depends on Core abstractions and is swappable without changing domain logic. |
| `tests/PiiRedaction.Core.Tests` | Test | Unit and integration tests for detectors, redactor, sanitizer, overlap rules, golden prompt scenarios (`PromptScenarioCatalog`), and LLM boundary assertions. |
| `tests/PiiRedaction.Infrastructure.Tests` | Test | Tests for mock LLM behavior and ONNX runner load semantics. |
@@ -272,7 +418,7 @@ In the POC, `MockChatClient` simulates the external provider without network I/O
| `IPromptSanitizer` | Core | `PromptSanitizer` | Unlikely to change; orchestrates detect + redact |
| `ILlmPromptService` | Core | `MockLlmPromptService` | Production adapter with telemetry, retry, policy |
| `IChatClient` | Microsoft.Extensions.AI | `MockChatClient` | Azure OpenAI, OpenAI, or other provider SDK |
| `IOnnxNerModelRunner` | Core | `OnnxNerModelRunner` | BERT WordPiece tokenization, ONNX inference, BIO label decoding |
| `IOnnxNerModelRunner` | Core | `RoutingOnnxNerModelRunner` | Script-based routing to English (BERT WordPiece) and Tamil (SentencePiece) ONNX models |
---
@@ -282,14 +428,18 @@ Runtime behavior is controlled via `appsettings.json` under the `PiiRedaction` s
| Setting | Effect |
|---------|--------|
| `OnnxModelPath` | Path to ONNX NER model (`models/ner-model.onnx` by default). Companion files `vocab.txt` and `ner-labels.txt` must live in the same directory. |
| `OnnxModelPath` | Legacy English model path (`models/ner-model.onnx`). Used as fallback when `models/en/` is absent. |
| `EnglishOnnxModelPath` | Primary English ONNX model (`models/en/ner-model.onnx`). |
| `TamilOnnxModelPath` | Tamil ONNX model (`models/ta/model.onnx`). |
| `EnableTamilNer` | When `false`, routing uses English model only. Default `true`. |
Download the model assets with `scripts/download-ner-model.ps1` (exports `dslim/bert-base-NER`).
Download model assets with `scripts/download-ner-model.ps1` and `scripts/download-tamil-ner-model.ps1`.
---
## Related Documentation
- [NER models](ner-models.md) — English/Tamil ONNX model IDs, assets, labels, and integration reference
- [README](../README.md) — build, run, configuration, and testing instructions
- [ServiceCollectionExtensions.cs](../src/PiiRedaction.ConsoleApp/DependencyInjection/ServiceCollectionExtensions.cs) — DI registration and detector ordering
- [PromptScenarioCatalog.cs](../tests/PiiRedaction.Core.Tests/TestSupport/PromptScenarioCatalog.cs) — focused golden pipeline scenarios including the canonical example

548
docs/ner-models.md Normal file
View File

@@ -0,0 +1,548 @@
# NER Models for PII Redaction
This document describes the **Named Entity Recognition (NER)** ONNX models at the core of the PII Redaction POC. Person-name detection is the only NER responsibility in this solution; structured identifiers (email, phone, PAN, domain IDs) are handled by regex and domain-rule detectors.
For pipeline placement, trust boundaries, and routing diagrams, see [architecture.md](architecture.md). For the Tamil/Tanglish implementation plan and success metrics, see [tamil-tanglish-ner-plan.md](tamil-tanglish-ner-plan.md).
---
## 1. Executive Summary
The POC uses **dual-model ONNX NER routing** to redact **person-name PII** before prompts reach an LLM:
| Script in prompt | Model invoked | Typical use case |
|------------------|---------------|------------------|
| Latin only (`LatinOnly`) | English (`dslim/bert-base-NER`) | English names, Indian names in Roman script, **Tanglish** |
| Tamil only (`TamilOnly`) | Tamil (`prachuryyaIITG/SampurNER_Tamil_IndicBERTv2`) | Tamil-script customer names |
| Mixed (`Mixed`) | **Both** models on the full text; spans merged | Code-mixed Indian CS prompts |
| No letters (`NoLetters`) | Neither | Digits-only or symbol-only text |
`RoutingOnnxNerModelRunner` classifies script via `ScriptRouter`, delegates to `EnglishOnnxNerRunner` and/or `TamilOnnxNerRunner`, and merges overlapping PERSON spans (longer span wins). Only **PERSON** entities are emitted to the redaction pipeline; all other NER labels are discarded.
---
## 2. English Model
### Hugging Face model ID
**[`dslim/bert-base-NER`](https://huggingface.co/dslim/bert-base-NER)**
### Architecture
| Property | Value |
|----------|-------|
| Base | BERT-base (uncased), ~110M parameters |
| Task | Token classification (NER) |
| Tokenizer | **WordPiece** via `vocab.txt` (`BertWordPieceEncoder`) |
| Runtime | ONNX via Microsoft.ML.OnnxRuntime |
| Export | Hugging Face Optimum (`ORTModelForTokenClassification`) or pre-exported ONNX from HF |
### Labels (BIO)
The English model uses standard CoNLL-style BIO tags. The POC maps only **person** labels to `PiiEntityType.Person`:
| Label | Mapped to PERSON |
|-------|------------------|
| `O` | No |
| `B-PER`, `I-PER` | Yes |
| `B-PERSON`, `I-PERSON` | Yes |
| `B-ORG`, `I-ORG`, `B-LOC`, `I-LOC`, `B-MISC`, `I-MISC` | No |
Full label list is written to `ner-labels.txt` at download time from the model `config.json` `id2label` map (typically 9 labels for this model).
Label matching is implemented in `NerLabelConfig.English`:
```19:22:src/PiiRedaction.Infrastructure/Onnx/NerLabelConfig.cs
private static bool IsEnglishPersonLabel(string label) =>
label is "B-PER" or "I-PER" or "B-PERSON" or "I-PERSON"
|| (label.EndsWith("-PER", StringComparison.Ordinal) &&
(label.StartsWith("B-", StringComparison.Ordinal) || label.StartsWith("I-", StringComparison.Ordinal)));
```
### Asset paths
| File | Primary path (`appsettings.json`) | Legacy fallback |
|------|----------------------------------|-----------------|
| ONNX model | `models/en/ner-model.onnx` | `models/ner-model.onnx` (`OnnxModelPath`) |
| Vocabulary | `models/en/vocab.txt` | `models/vocab.txt` |
| Labels | `models/en/ner-labels.txt` | `models/ner-labels.txt` |
`EnglishOnnxNerRunner` resolves the model path with primary + legacy fallback:
```15:22:src/PiiRedaction.Infrastructure/Onnx/EnglishOnnxNerRunner.cs
var modelPath = OnnxAssetPathResolver.ResolveModelPath(
options.Value.EnglishOnnxModelPath,
options.Value.OnnxModelPath);
var modelDirectory = Path.GetDirectoryName(modelPath) ?? Environment.CurrentDirectory;
var labels = OnnxAssetPathResolver.LoadLabels(modelDirectory);
var encoder = new BertWordPieceEncoder(modelDirectory, logger);
_runner = new OnnxTokenClassifierRunner(modelPath, encoder, NerLabelConfig.English, labels, logger);
```
> **Note:** `scripts/download-ner-model.ps1` writes assets to `models/` (repository root). For the configured primary path, copy or move them into `models/en/`, or rely on the `OnnxModelPath` fallback.
### Download script
```powershell
.\scripts\download-ner-model.ps1
```
Or with Python directly:
```bash
python scripts/download-ner-model.py
```
**Behavior:**
1. If Python + Optimum are available → exports `dslim/bert-base-NER` to ONNX under `models/`.
2. Otherwise → downloads pre-exported ONNX from `https://huggingface.co/dslim/bert-base-NER/resolve/main/onnx/` (`model.onnx`, `vocab.txt`, `config.json` → `ner-labels.txt`).
---
## 3. Tamil Model
### Hugging Face model ID
**[`prachuryyaIITG/SampurNER_Tamil_IndicBERTv2`](https://huggingface.co/prachuryyaIITG/SampurNER_Tamil_IndicBERTv2)**
(SampurNER Tamil IndicBERTv2 — fine-grained NER for Tamil script.)
### Why SampurNER IndicBERTv2 vs MuRIL
| Criterion | SampurNER Tamil IndicBERTv2 | MuRIL (fallback candidate) |
|-----------|----------------------------|----------------------------|
| Tamil NER training | Fine-grained SampurNER dataset (Tamil-specific labels) | General multilingual; NER requires separate fine-tune |
| Model size | ~0.3B parameters (IndicBERTv2, ~278M base) | ~0.6B parameters |
| POC fit | Lighter memory footprint; ONNX export path validated in this repo | Reserved for Phase 5 if Tamil recall is insufficient |
| Indian financial context | Trained on Indian-language NER corpus; person subtypes map cleanly to PERSON | Heavier; eval-driven swap only |
See [tamil-tanglish-ner-plan.md](tamil-tanglish-ner-plan.md) §3 for the original selection rationale.
### Architecture
| Property | Value |
|----------|-------|
| Base | IndicBERTv2 (AI4Bharat), ~0.3B parameters |
| Task | Fine-grained token classification |
| Tokenizer | **WordPiece** when `vocab.txt` is present (this repo's export path); SentencePiece fallback if `sentencepiece.bpe.model` / `spiece.model` exists |
| Runtime | Same shared `OnnxTokenClassifierRunner` as English |
### Tokenizer: WordPiece, not SentencePiece (in practice)
The Hugging Face repo for this model does **not** ship a SentencePiece model file. The download scripts extract **WordPiece** assets from `tokenizer.json` → `vocab.txt`. `TokenClassifierEncoderFactory` prefers `vocab.txt`:
```10:19:src/PiiRedaction.Infrastructure/Onnx/TokenClassifierEncoderFactory.cs
public static ITokenClassifierEncoder Create(string modelDirectory, ILogger logger)
{
var vocabPath = OnnxAssetPathResolver.ResolveAssetPath(Path.Combine(modelDirectory, "vocab.txt"));
if (File.Exists(vocabPath))
{
logger.LogInformation(
"Using WordPiece tokenizer (vocab.txt) from {ModelDirectory}.",
modelDirectory);
return new BertWordPieceEncoder(modelDirectory, logger);
}
```
The PowerShell Tamil download script emits an explicit warning when WordPiece assets are saved instead of SentencePiece.
### Labels (fine-grained person tags)
SampurNER uses fine-grained BIO tags (e.g. `B-person-politician`, `I-person-artist`, `B-location`, `O`). The POC treats **any label containing `person`** (case-insensitive) as a person span:
```24:25:src/PiiRedaction.Infrastructure/Onnx/NerLabelConfig.cs
private static bool IsTamilPersonLabel(string label) =>
label.Contains("person", StringComparison.OrdinalIgnoreCase);
```
Unit tests lock this behavior:
```20:27:tests/PiiRedaction.Infrastructure.Tests/Onnx/NerLabelConfigTests.cs
[TestCase("B-person-politician", true)]
[TestCase("I-person-artist", true)]
[TestCase("B-location", false)]
[TestCase("O", false)]
public void Tamil_IsPersonLabel_MatchesFineGrainedTags(string label, bool expected)
{
NerLabelConfig.Tamil.IsPersonLabel(label).Should().Be(expected);
}
```
### Asset paths
| File | Path |
|------|------|
| ONNX model | `models/ta/model.onnx` |
| Tokenizer | `models/ta/vocab.txt` (WordPiece, preferred) **or** `models/ta/sentencepiece.bpe.model` |
| Labels | `models/ta/ner-labels.txt` |
| Optional | `models/ta/tokenizer.json` (intermediate export artifact) |
### Download script
```powershell
.\scripts\download-tamil-ner-model.ps1
```
Or with Python directly:
```bash
python scripts/download-tamil-ner-model.py
```
**Behavior:**
1. Python + Optimum → full export to `models/ta/` including ONNX, labels, and tokenizer assets.
2. PowerShell fallback → downloads `onnx/model.onnx` from Hugging Face when published; otherwise requires Python export (pre-exported ONNX may return 404).
`TamilOnnxNerRunner` wiring:
```15:19:src/PiiRedaction.Infrastructure/Onnx/TamilOnnxNerRunner.cs
var modelPath = OnnxAssetPathResolver.ResolveModelPath(options.Value.TamilOnnxModelPath);
var modelDirectory = Path.GetDirectoryName(modelPath) ?? Environment.CurrentDirectory;
var labels = OnnxAssetPathResolver.LoadLabels(modelDirectory);
var encoder = TokenClassifierEncoderFactory.Create(modelDirectory, logger);
_runner = new OnnxTokenClassifierRunner(modelPath, encoder, NerLabelConfig.Tamil, labels, logger);
```
---
## 4. Why These Models
Evidence-based rationale for this Indian financial POC:
| Requirement | Decision |
|-------------|----------|
| **English + Indian Latin names** | `dslim/bert-base-NER` is industry-standard, pre-integrated, and handles many Indian names in Roman script (e.g. `Ravi Kumar`, `Anita Sharma`) |
| **Tamil script names** | English BERT is out-of-vocabulary for Tamil letters (U+0B80U+0BFF); a Tamil-trained NER model is required |
| **Tanglish (Roman-script Tamil)** | Routed to the **English** model only (`ScriptComposition.LatinOnly`); no Tamil ONNX on Latin-only text |
| **Code-mixed prompts** | Both models run on the full prompt; `MergePersonSpans` deduplicates overlaps |
| **Deployable size** | English ~431 MB ONNX (FP32); Tamil IndicBERTv2 ~0.3B params — smaller than MuRIL ~0.6B |
| **ONNX export path** | Both models export via Hugging Face Optimum; English has pre-exported ONNX on HF; Tamil may require local Python export |
| **PERSON-only scope** | POC redacts person names via NER; org/location/misc labels are intentionally ignored to limit false positives |
---
## 5. How They Integrate
### Configuration (`appsettings.json`)
```1:8:src/PiiRedaction.ConsoleApp/appsettings.json
{
"PiiRedaction": {
"OnnxModelPath": "models/ner-model.onnx",
"EnglishOnnxModelPath": "models/en/ner-model.onnx",
"TamilOnnxModelPath": "models/ta/model.onnx",
"EnableTamilNer": true
}
}
```
Options type:
```3:14:src/PiiRedaction.Core/Configuration/PiiRedactionOptions.cs
public sealed class PiiRedactionOptions
{
public const string SectionName = "PiiRedaction";
public string OnnxModelPath { get; set; } = "models/ner-model.onnx";
public string EnglishOnnxModelPath { get; set; } = "models/en/ner-model.onnx";
public string TamilOnnxModelPath { get; set; } = "models/ta/model.onnx";
public bool EnableTamilNer { get; set; } = true;
}
```
Set `EnableTamilNer` to `false` for English-only routing.
### Dependency injection
```34:36:src/PiiRedaction.ConsoleApp/DependencyInjection/ServiceCollectionExtensions.cs
services.AddSingleton<EnglishOnnxNerRunner>();
services.AddSingleton<TamilOnnxNerRunner>();
services.AddSingleton<IOnnxNerModelRunner, RoutingOnnxNerModelRunner>();
```
`OnnxNerPiiDetector` consumes `IOnnxNerModelRunner` (the router) and returns `[]` when no model is available — **fail-open** for person detection.
### Script routing (`ScriptRouter`)
```11:41:src/PiiRedaction.Core/Detection/ScriptRouter.cs
public ScriptComposition GetComposition(string text)
{
ArgumentNullException.ThrowIfNull(text);
var hasLatin = false;
var hasTamil = false;
foreach (var character in text)
{
if (IsTamilLetter(character))
{
hasTamil = true;
}
else if (char.IsAsciiLetter(character))
{
hasLatin = true;
}
if (hasLatin && hasTamil)
{
return ScriptComposition.Mixed;
}
}
// ...
return hasTamil ? ScriptComposition.TamilOnly : ScriptComposition.LatinOnly;
}
```
### Routing runner (`RoutingOnnxNerModelRunner`)
```39:79:src/PiiRedaction.Infrastructure/Onnx/RoutingOnnxNerModelRunner.cs
public IReadOnlyList<PiiEntity> PredictEntities(string text)
{
var composition = _scriptRouter.GetComposition(text);
var entities = new List<PiiEntity>();
switch (composition)
{
case ScriptComposition.LatinOnly:
if (_englishRunner.IsModelAvailable)
{
entities.AddRange(_englishRunner.PredictEntities(text));
}
break;
case ScriptComposition.TamilOnly:
if (_enableTamilNer && _tamilRunner.IsModelAvailable)
{
entities.AddRange(_tamilRunner.PredictEntities(text));
}
break;
case ScriptComposition.Mixed:
if (_englishRunner.IsModelAvailable)
{
entities.AddRange(_englishRunner.PredictEntities(text));
}
if (_enableTamilNer && _tamilRunner.IsModelAvailable)
{
entities.AddRange(_tamilRunner.PredictEntities(text));
}
break;
case ScriptComposition.NoLetters:
break;
}
return MergePersonSpans(entities);
}
```
### Shared inference (`OnnxTokenClassifierRunner`)
Both runners share:
- **Encode** → `input_ids`, `attention_mask`, optional `token_type_ids`
- **Argmax** over per-token logits
- **BIO decode** → `PiiEntityType.Person` with `PiiDetectionSource.Ner`
- **Max sequence length: 128 tokens**
```13:13:src/PiiRedaction.Infrastructure/Onnx/OnnxTokenClassifierRunner.cs
private const int MaxSequenceLength = 128;
```
### End-to-end flow
```
Prompt → CompositePiiDetector → OnnxNerPiiDetector
→ RoutingOnnxNerModelRunner → ScriptRouter
→ EnglishOnnxNerRunner / TamilOnnxNerRunner
→ OnnxTokenClassifierRunner → PERSON entities
→ PlaceholderPiiRedactor → <PERSON_n>
```
---
## 6. Evidence from Codebase
### Real-model tests (`Category=RealModel`)
English direct inference — `RealNerModelRunnerTests`:
```15:47:tests/PiiRedaction.Infrastructure.Tests/Onnx/RealNerModelRunnerTests.cs
[Category("RealModel")]
public sealed class RealNerModelRunnerTests : RealNerModelFixture
{
[TestCase("Customer Ravi Kumar called about billing.", "Ravi", "Ravi Kumar")]
[TestCase("Mr. John Smith called about a duplicate debit.", "John", "John Smith")]
public void PredictEntities_DetectsPersonWithCorrectSpan(...)
```
English pipeline — `RealNerPipelineTests` (`Category=RealModel`): canonical `FullFinancialWithCustomer`, multi-person, clean-ticket negative.
Fixture skips when model missing:
```5:6:tests/TestSupport.Shared/RealNerModelPaths.cs
public const string ModelMissingMessage =
"ONNX model not found. Run scripts/download-ner-model.ps1 from the repository root.";
```
Resolves `models/en/ner-model.onnx` then `models/ner-model.onnx`.
### Tamil tests (`Category=TamilNer`)
Direct Tamil runner — `RealTamilNerModelRunnerTests`:
```9:69:tests/PiiRedaction.Infrastructure.Tests/Onnx/RealTamilNerModelRunnerTests.cs
[Category("TamilNer")]
public sealed class RealTamilNerModelRunnerTests : RealTamilNerModelFixture
{
[TestCase("வாடிக்கையாளர் ராஜேஷ் குமார் அழைத்தார்.", "ராஜேஷ்", "ராஜேஷ் குமார்")]
public void PredictEntities_TamilScript_DetectsPersonEntity(...)
// ...
[TestCase("Customer Senthil phone 9876543210", "Senthil")]
public void PredictEntities_TanglishLatinScript_DoesNotInvokeTamilRunner(...)
```
Routed pipeline — `RealTamilPipelineTests` covers Tamil-only, Tanglish (English path), mixed, full financial, and clean Tamil negative.
### Console samples (`SamplePromptCatalog`)
Tamil/Tanglish/mixed samples (indices 1014):
| Sample | Category | Input excerpt |
|--------|----------|---------------|
| `TamilCustomerNameOnly` | NER (Tamil) | `வாடிக்கையாளர் ராஜேஷ் குமார் …` |
| `TamilWithPhonePan` | NER (Tamil) + Regex | Tamil person + phone + PAN |
| `TanglishCustomer` | NER (English/Tanglish) | `Customer Senthil phone 9876543210 …` |
| `MixedTamilEnglish` | NER (Mixed) | `வாடிக்கையாளர் Ravi Kumar phone …` |
| `TamilFullFinancial` | NER (Tamil) + Regex + Domain | Tamil canonical demo |
Run all samples (including Tamil) with no flags:
```bash
dotnet run --project src/PiiRedaction.ConsoleApp
```
Or a single Tamil sample:
```bash
dotnet run --project src/PiiRedaction.ConsoleApp -- --name TamilCustomerNameOnly
```
### Expected console output (English canonical)
When models are loaded, person names appear as `[PERSON]` with source `Ner`:
```
Detected PII:
[PERSON ] Ravi Kumar (Ner)
[EMAIL ] ravi.kumar@gmail.com (Regex)
...
Sanitized Prompt:
Customer <PERSON_1> with email <EMAIL_1> ...
```
---
## 7. Model Assets Table
All model binaries are **gitignored**; only `.gitkeep` placeholders are committed.
| Directory | File | Approx. size | Gitignored | Purpose |
|-----------|------|--------------|------------|---------|
| `models/` or `models/en/` | `ner-model.onnx` | ~431 MB | Yes | English BERT NER (FP32 ONNX from HF) |
| `models/` or `models/en/` | `vocab.txt` | ~213 KB | Yes | WordPiece vocabulary |
| `models/` or `models/en/` | `ner-labels.txt` | < 1 KB | Yes | BIO label index (one per line) |
| `models/ta/` | `model.onnx` | ~1.2 GB (FP32 export, varies) | Yes | Tamil IndicBERT NER |
| `models/ta/` | `vocab.txt` | varies | Yes | WordPiece vocab (preferred tokenizer) |
| `models/ta/` | `tokenizer.json` | varies | Yes | HF tokenizer export (optional) |
| `models/ta/` | `sentencepiece.bpe.model` | varies | Yes | SentencePiece (if present instead of vocab) |
| `models/ta/` | `ner-labels.txt` | few KB | Yes | Fine-grained SampurNER labels |
`.gitignore` entries:
```
models/*.onnx
models/vocab.txt
models/ner-labels.txt
models/*.json
models/en/*
models/ta/*
```
English size reference: [docs/git-xenovex-setup.md](git-xenovex-setup.md) notes ~431 MB for the English ONNX file.
---
## 8. Limitations
| Limitation | Detail |
|------------|--------|
| **Tanglish on English model only** | Roman-script Tanglish (`Customer Senthil`) is classified `LatinOnly` and handled by English BERT. Recall is best-effort and inconsistent for non-standard spellings. Tamil ONNX is **not** invoked on Latin-only text. |
| **Fail-open if model missing** | `OnnxNerPiiDetector` and routing runners return `[]` when models are unavailable. Person names are **not** redacted; regex/domain layers still run. No regex fallback for names. |
| **128 token limit** | `OnnxTokenClassifierRunner` truncates encoding at 128 tokens. Very long prompts may miss person names beyond the window. |
| **PERSON-only NER scope** | Organization, location, and misc NER labels are ignored. Only person spans become `<PERSON_n>`. |
| **Mixed-script merge** | When both models run, overlapping spans are deduped by length; shorter overlapping spans are dropped. |
| **Tamil ONNX availability** | Pre-exported Tamil ONNX may not exist on Hugging Face; local Python export is often required. |
| **No fail-closed mode** | Missing NER does not block sanitization or LLM calls (optional Phase 5 enhancement). |
---
## 9. How to Reproduce
### Download models
From the repository root:
```powershell
.\scripts\download-ner-model.ps1
.\scripts\download-tamil-ner-model.ps1
```
If Tamil PowerShell download fails with a 404 on `onnx/model.onnx`, install Python 3.12+ and re-run:
```powershell
winget install Python.Python.3.12 --accept-package-agreements --accept-source-agreements
.\scripts\download-tamil-ner-model.ps1 -Python "$env:LOCALAPPDATA\Programs\Python\Python312\python.exe"
```
Optional: copy English assets from `models/` to `models/en/` to match `EnglishOnnxModelPath`.
### Verify with tests
```bash
dotnet build
dotnet test
dotnet test --filter "Category=RealModel"
dotnet test --filter "Category=TamilNer"
dotnet test --logger "console;verbosity=detailed"
```
Tests skip gracefully when the corresponding ONNX files are absent.
### Verify with console
```bash
dotnet run --project src/PiiRedaction.ConsoleApp -- --name CustomerNameOnly
dotnet run --project src/PiiRedaction.ConsoleApp -- --name TamilCustomerNameOnly
dotnet run --project src/PiiRedaction.ConsoleApp -- --name TanglishCustomer
```
### Disable Tamil routing (English-only)
Set in `appsettings.json`:
```json
"EnableTamilNer": false
```
---
## Related Documentation
- [README.md](../README.md) — build, run, and test overview
- [architecture.md](architecture.md) — dual-model routing diagrams and trust boundary
- [tamil-tanglish-ner-plan.md](tamil-tanglish-ner-plan.md) — implementation phases and Tanglish expectations

View File

@@ -0,0 +1,307 @@
# Tamil / Tanglish NER — Implementation Plan
**Goal:** Raise language coverage from ~15% to production-viable for Tamil script and Tanglish (Roman-script Tamil-English) customer prompts, without changing the secure LLM boundary pattern.
**Status:** Phase 13 implemented
**Approach:** Dual-model ONNX NER routing (English + Tamil) + lightweight text normalization + optional Tanglish heuristics
**Estimated effort:** 46 engineering days across 4 phases
---
## 1. Current State vs Gap
| Capability | Today | Tamil script | Tanglish (Latin) |
|------------|-------|--------------|------------------|
| Phone, PAN, Aadhaar, email, domain IDs | Regex + domain rules | Works (ASCII digits) | Works |
| Person names | `dslim/bert-base-NER` (English BERT) | **Fails** — out of vocabulary | **Partial** — inconsistent |
| Script / language routing | None | N/A | N/A |
| Tamil numerals (௦–௯) | Not normalized | **May miss** phone/Aadhaar | N/A |
| Label-aware cues (`பெயர்`, `peru`, `enga peru`) | None | **Misses** contextual names | **Misses** |
**Root cause:** Person detection is a single English-only ONNX model behind `IOnnxNerModelRunner``OnnxNerPiiDetector`. Regex/domain layers are already language-agnostic.
---
## 2. Target Architecture
No change to the trust boundary: `PromptSanitizer``CompositePiiDetector``PlaceholderPiiRedactor` → sanitized text only to LLM.
Only the **NER adapter** expands:
```mermaid
flowchart TD
text["Prompt text"]
router["ScriptRouter (Core)"]
routing["RoutingOnnxNerModelRunner"]
en["EnglishOnnxRunner\nBERT WordPiece"]
ta["TamilOnnxRunner\nIndicBERT SentencePiece"]
nerDet["OnnxNerPiiDetector"]
composite["CompositePiiDetector"]
text --> composite
text --> nerDet
nerDet --> routing
routing --> router
router -->|"LatinOnly / Mixed"| en
router -->|"TamilOnly / Mixed"| ta
en --> routing
ta --> routing
```
### Routing rules
| `ScriptComposition` | Models invoked | Tanglish note |
|---------------------|----------------|---------------|
| `LatinOnly` | English NER only | Tanglish names in Roman script |
| `TamilOnly` | Tamil NER only | Tamil script names |
| `Mixed` | **Both**, merge person spans | Common in Indian CS prompts |
| `NoLetters` | Neither (or English fallback off) | Digits-only prompts |
**Merge inside `RoutingOnnxNerModelRunner`:** dedupe overlapping person spans (prefer longer span; tie-break Tamil vs English by start index order).
---
## 3. Model Selection
| Role | Model | Rationale |
|------|-------|-----------|
| English / Tanglish (Latin) | **Keep** `dslim/bert-base-NER` | Already integrated; works for many Indian names in Latin script |
| Tamil script | **`prachuryyaIITG/SampurNER_Tamil_IndicBERTv2`** | Tamil NER; lighter than MuRIL (~0.6B) |
| Fallback (optional Phase 5) | MuRIL Tamil NER | Only if IndicBERT recall is insufficient on eval set |
### Asset layout
```
models/
en/
ner-model.onnx # or model.onnx (BERT export)
vocab.txt
ner-labels.txt
ta/
model.onnx
sentencepiece.bpe.model # or tokenizer.json from HF export
ner-labels.txt
ner-model.onnx # legacy path — keep for backward compatibility
```
### Label mapping (Tamil fine-grained NER)
SampurNER uses fine-grained tags (e.g. `B-person-politician`, `I-person-artist`). Map **any label containing `person`** (case-insensitive) → `PiiEntityType.Person`.
English labels remain: `B-PER`, `I-PER`, `B-PERSON`, `I-PERSON`.
---
## 4. Implementation Phases
### Phase 1 — Generic ONNX token classifier (12 days)
**Objective:** Refactor `OnnxNerModelRunner` so BERT and SentencePiece are pluggable.
| Action | Location |
|--------|----------|
| Add `ITokenClassifierEncoder` + `EncodedSequence` | `Infrastructure/Onnx/` |
| `BertWordPieceEncoder` — extract from current runner | Infrastructure |
| `SentencePieceEncoder` — IndicBERT tokenizer | Infrastructure |
| `OnnxTokenClassifierRunner` — shared inference + BIO decode | Infrastructure |
| `NerLabelConfig` — English vs Tamil person label predicates | Infrastructure |
| `OnnxAssetPathResolver` — resolve model dir from repo root | Infrastructure |
| Thin wrappers: `EnglishOnnxNerRunner`, `TamilOnnxNerRunner` | Infrastructure |
**Backward compat:** If `models/en/` missing, fall back to `OnnxModelPath` (`models/ner-model.onnx`).
**No behavior change** until Phase 3 wiring — existing tests must pass.
---
### Phase 2 — Tamil model download + config (0.51 day)
| Action | Details |
|--------|---------|
| `scripts/download-tamil-ner-model.ps1` + `.py` | Mirror `download-ner-model.ps1`; export via `optimum-cli export onnx --task token-classification` |
| Optional: `scripts/download-all-ner-models.ps1` | Calls English + Tamil scripts |
| Extend `PiiRedactionOptions` | `EnglishOnnxModelPath`, `TamilOnnxModelPath`, `EnableTamilNer` (default `true`) |
| Update `appsettings.json` | New paths under `PiiRedaction` section |
| `.gitignore` | `models/ta/*`, `models/en/*` (same as today for onnx/vocab) |
---
### Phase 3 — Script routing + DI (0.51 day)
| Action | Location |
|--------|----------|
| `ScriptRouter` + `ScriptComposition` enum | `Core/Detection/` |
| `RoutingOnnxNerModelRunner` implements `IOnnxNerModelRunner` | Infrastructure |
| DI registration | `ServiceCollectionExtensions.cs` |
```csharp
services.AddSingleton<EnglishOnnxNerRunner>();
services.AddSingleton<TamilOnnxNerRunner>();
services.AddSingleton<IOnnxNerModelRunner, RoutingOnnxNerModelRunner>();
```
`OnnxNerPiiDetector` and `CompositePiiDetector` **unchanged**.
---
### Phase 4 — Tests, samples, docs (1 day)
#### Unit tests
| Test class | Coverage |
|------------|----------|
| `ScriptRouterTests` | Tamil-only, Latin-only, mixed, no-letters, boundary chars U+0B80/U+0BFF |
| `RoutingOnnxNerModelRunnerTests` | Fake EN/TA runners; mixed script merges both |
| `NerLabelConfigTests` | Tamil fine-grained person labels map correctly |
#### Real-model tests (`Category=RealModel` or `Category=TamilNer`)
| Scenario | Input example | Assert |
|----------|---------------|--------|
| Tamil name | `வாடிக்கையாளர் ராஜேஷ் தொலைபேசி 9876543210` | `<PERSON_1>`, phone redacted |
| Tanglish name | `Customer Senthil phone 9876543210` | person + phone (best-effort) |
| Mixed | `Rajesh மற்றும் Priya` | both persons redacted |
| Clean Tamil | `பணத்தை திரும்பப் பெறுவது எப்படி?` | no false positives |
| Canonical English | existing golden tests | no regression |
Skip gracefully when `models/ta/model.onnx` missing (mirror `RealNerModelFixture`).
#### Console samples
Add to `SamplePromptCatalog.cs`:
- `TamilCustomerName` — Tamil script person
- `TanglishCustomerName``enga peru Rajesh` / `Customer Senthil`
- `MixedTamilEnglish` — code-mixed prompt
#### Docs
- Update `README.md` ONNX setup (dual models)
- Update `docs/architecture.md` NER section
- Link this plan from README
---
### Phase 5 — Optional enhancements (post-MVP)
| Enhancement | Benefit | Effort |
|-------------|---------|--------|
| **Unicode digit normalization** pre-pass | Tamil numerals → ASCII for regex | 0.5 day |
| **Label-based regex** (`பெயர்`, `peru`, `peyar`, `enga peru`) | Tanglish recall without ML | 0.5 day |
| **Tamil name gazetteer** `IPiiDetector` | High precision for top names | 1 day |
| **Fail-closed policy** when NER unavailable | Compliance option | 0.5 day |
| MuRIL model swap | Higher Tamil recall | eval-driven |
---
## 5. Tanglish — Realistic Expectations
| Input type | Primary handler | Expected recall |
|------------|-----------------|-----------------|
| Tamil script names | Tamil ONNX NER | High (with eval tuning) |
| Standard Latin Indian names (`Ravi Kumar`) | English ONNX NER | High (already works) |
| Tanglish spellings (`Senthil`, `senthil`, `Centhil`) | English NER + optional gazetteer | Medium |
| Code-mixed (`Rajesh oda account ACC-123456`) | English NER + domain regex | Mediumhigh for IDs; name variable |
**MVP target:** Tamil script person names reliably redacted; Tanglish improved but not 100% without Phase 5 heuristics.
---
## 6. Success Metrics
Before marking language gap closed, run an **eval set of 2030 real prompts** (anonymized production samples):
| Metric | MVP target |
|--------|------------|
| Tamil script person-name recall | ≥ 85% |
| Tanglish person-name recall | ≥ 70% (with English model + optional heuristics) |
| False positive rate (clean prompts) | ≤ 5% |
| Structured PII (phone/PAN/domain) in Tamil prompts | ≥ 95% (regex layer) |
| Regression on English canonical demo | 100% (existing golden tests) |
---
## 7. Files to Create / Modify (checklist)
### New files
- `src/PiiRedaction.Core/Detection/ScriptRouter.cs`
- `src/PiiRedaction.Core/Detection/ScriptComposition.cs`
- `src/PiiRedaction.Infrastructure/Onnx/OnnxTokenClassifierRunner.cs`
- `src/PiiRedaction.Infrastructure/Onnx/BertWordPieceEncoder.cs`
- `src/PiiRedaction.Infrastructure/Onnx/SentencePieceEncoder.cs`
- `src/PiiRedaction.Infrastructure/Onnx/ITokenClassifierEncoder.cs`
- `src/PiiRedaction.Infrastructure/Onnx/NerLabelConfig.cs`
- `src/PiiRedaction.Infrastructure/Onnx/OnnxAssetPathResolver.cs`
- `src/PiiRedaction.Infrastructure/Onnx/EnglishOnnxNerRunner.cs`
- `src/PiiRedaction.Infrastructure/Onnx/TamilOnnxNerRunner.cs`
- `src/PiiRedaction.Infrastructure/Onnx/RoutingOnnxNerModelRunner.cs`
- `scripts/download-tamil-ner-model.ps1` / `.py`
- `tests/.../ScriptRouterTests.cs`
- `tests/.../RoutingOnnxNerModelRunnerTests.cs`
- `tests/.../RealTamilNerPipelineTests.cs`
- `tests/TestSupport.Shared/RealTamilModelFixture.cs`
### Modified files
- `PiiRedactionOptions.cs` — dual model paths
- `ServiceCollectionExtensions.cs` — routing DI
- `appsettings.json` — config
- `SamplePromptCatalog.cs` — Tamil/Tanglish demos
- `ProductionPipelineFactory.cs``CreateWithRoutingRealModel()` for tests
- `RealNerModelFixture.cs` / paths — support EN + TA
- `README.md`, `docs/architecture.md`
- `.gitignore``models/en/`, `models/ta/`
### Unchanged (by design)
- `PromptSanitizer`, `PlaceholderPiiRedactor`, `CompositePiiDetector`
- `RegexPiiDetector`, `DomainRulePiiDetector`
- `MockLlmPromptService` / LLM boundary
---
## 8. Rollout & Risk
| Risk | Mitigation |
|------|------------|
| Tamil model export fails on Windows | PowerShell fallback downloads pre-exported ONNX from Hugging Face |
| Larger memory (two models) | Lazy-load Tamil runner only when `EnableTamilNer` and Tamil script detected |
| Fine-grained label mismatch | Load labels from `ner-labels.txt`; unit test label config |
| Tanglish disappointment | Set stakeholder expectation in README; Phase 5 heuristics |
| CI without models | Fast tests use fakes; `Category=TamilNer` skips like `RealModel` |
**Feature flag:** `EnableTamilNer=false` reverts to English-only behavior for gradual rollout.
---
## 9. Command Reference (after implementation)
```powershell
# Download both models
.\scripts\download-ner-model.ps1
.\scripts\download-tamil-ner-model.ps1
# Run Tamil-focused console sample
dotnet run --project src/PiiRedaction.ConsoleApp -- --name TamilCustomerName
# Tests
dotnet test
dotnet test --filter "Category=TamilNer"
dotnet test --filter "Category=RealModel"
```
---
## 10. Approval Checklist
- [ ] Stakeholder sign-off on dual-model approach (vs single multilingual model)
- [ ] Tamil eval prompt set collected (2030 samples)
- [ ] Xenovex CI policy: models downloaded in pipeline or tests skip
- [x] Phase 13 implementation PR
- [ ] Phase 4 eval metrics met
- [ ] Optional Phase 5 for Tanglish heuristics if recall &lt; 70%
---
**Next step:** Implement Phase 1 in a feature branch (`feature/tamil-tanglish-ner`), open PR to `main` on `xts.xenovex.com/Bilal-Nazer-Ali/llm-pii-poc`.

0
models/en/.gitkeep Normal file
View File

0
models/ta/.gitkeep Normal file
View File

View File

@@ -0,0 +1,229 @@
# Downloads prachuryyaIITG/SampurNER_Tamil_IndicBERTv2 ONNX assets to models/ta/ for the PII Redaction POC.
param(
[string]$Python = "python"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$modelsDir = Join-Path (Join-Path $repoRoot "models") "ta"
$scriptPath = Join-Path $PSScriptRoot "download-tamil-ner-model.py"
$modelId = "prachuryyaIITG/SampurNER_Tamil_IndicBERTv2"
$baseUrl = "https://huggingface.co/$modelId/resolve/main"
function Ensure-ModelsDirectory {
New-Item -ItemType Directory -Force -Path $modelsDir | Out-Null
}
function Download-HuggingFaceAsset {
param(
[string]$RelativePath,
[string]$Destination
)
$url = "$baseUrl/$RelativePath"
Write-Host "Downloading $url"
Invoke-WebRequest -Uri $url -OutFile $Destination -UseBasicParsing
}
function Export-LabelsFromConfig {
param([string]$ConfigPath, [string]$LabelsPath)
$config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$labelMap = @{}
foreach ($property in $config.id2label.PSObject.Properties) {
$labelMap[[int]$property.Name] = [string]$property.Value
}
$labels = for ($index = 0; $index -lt $labelMap.Count; $index++) {
$labelMap[$index]
}
$labels | Set-Content -Path $LabelsPath -Encoding utf8
}
function Resolve-PythonExecutable {
param([string]$Preferred = "python")
if ($Preferred -ne "python") {
if ((Get-Command $Preferred -ErrorAction SilentlyContinue) -and
-not ((Get-Command $Preferred).Source -like "*WindowsApps*")) {
return $Preferred
}
}
$candidates = @(
(Join-Path $env:LOCALAPPDATA "Programs\Python\Python313\python.exe"),
(Join-Path $env:LOCALAPPDATA "Programs\Python\Python312\python.exe"),
(Join-Path $env:LOCALAPPDATA "Programs\Python\Python311\python.exe"),
"C:\Program Files\Python312\python.exe",
"C:\Program Files\Python313\python.exe"
)
foreach ($candidate in $candidates) {
if (Test-Path $candidate) {
return $candidate
}
}
$pythonCommand = Get-Command python -ErrorAction SilentlyContinue
if ($pythonCommand -and $pythonCommand.Source -notlike "*WindowsApps*") {
return $pythonCommand.Source
}
return $null
}
function Export-VocabFromTokenizerJson {
param([string]$TokenizerJsonPath, [string]$VocabPath)
$tokenizer = Get-Content $TokenizerJsonPath -Raw | ConvertFrom-Json
$vocab = $tokenizer.model.vocab
if (-not $vocab) {
return $false
}
$orderedTokens = $vocab.PSObject.Properties |
Sort-Object { [int]$_.Value } |
ForEach-Object { $_.Name }
$orderedTokens | Set-Content -Path $VocabPath -Encoding utf8
return $true
}
function Copy-TokenizerAssets {
param([string]$SourceDir)
foreach ($name in @("sentencepiece.bpe.model", "spiece.model", "tokenizer.model")) {
$source = Join-Path $SourceDir $name
if (Test-Path $source) {
$destination = Join-Path $modelsDir $name
Copy-Item $source $destination -Force
return @($destination)
}
}
$tokenizerJsonSource = Join-Path $SourceDir "tokenizer.json"
if (Test-Path $tokenizerJsonSource) {
$tokenizerJsonDestination = Join-Path $modelsDir "tokenizer.json"
Copy-Item $tokenizerJsonSource $tokenizerJsonDestination -Force
$saved = @($tokenizerJsonDestination)
$vocabPath = Join-Path $modelsDir "vocab.txt"
if (Export-VocabFromTokenizerJson -TokenizerJsonPath $tokenizerJsonDestination -VocabPath $vocabPath) {
$saved += $vocabPath
}
Write-Warning (
"No SentencePiece model on Hugging Face; saved WordPiece assets ($(
($saved | ForEach-Object { Split-Path $_ -Leaf }) -join ', '
)). TamilOnnxNerRunner uses vocab.txt (WordPiece) when present, otherwise SentencePiece model files."
)
return $saved
}
return @()
}
function Download-WithPowerShell {
Ensure-ModelsDirectory
$modelPath = Join-Path $modelsDir "model.onnx"
$configPath = Join-Path $modelsDir "config.json"
$labelsPath = Join-Path $modelsDir "ner-labels.txt"
$tempDir = Join-Path $modelsDir "_download_temp"
New-Item -ItemType Directory -Force -Path $tempDir | Out-Null
try {
Download-HuggingFaceAsset -RelativePath "onnx/model.onnx" -Destination $modelPath
}
catch {
Write-Host "Pre-exported ONNX not found; downloading config and tokenizer for manual export..."
Download-HuggingFaceAsset -RelativePath "config.json" -Destination $configPath
Export-LabelsFromConfig -ConfigPath $configPath -LabelsPath $labelsPath
$tokenizerJsonPath = Join-Path $modelsDir "tokenizer.json"
try {
Download-HuggingFaceAsset -RelativePath "tokenizer.json" -Destination $tokenizerJsonPath
$vocabPath = Join-Path $modelsDir "vocab.txt"
Export-VocabFromTokenizerJson -TokenizerJsonPath $tokenizerJsonPath -VocabPath $vocabPath | Out-Null
}
catch {
Write-Host "tokenizer.json not available from Hugging Face."
}
foreach ($name in @("sentencepiece.bpe.model", "spiece.model", "tokenizer.model")) {
try {
Download-HuggingFaceAsset -RelativePath $name -Destination (Join-Path $modelsDir $name)
break
}
catch {
continue
}
}
$pythonExe = Resolve-PythonExecutable -Preferred $Python
if ($pythonExe) {
throw (
"Tamil ONNX model is not published on Hugging Face. Re-run with Python export:`n" +
" .\scripts\download-tamil-ner-model.ps1 -Python `"$pythonExe`""
)
}
throw @"
Tamil ONNX model is not published on Hugging Face (onnx/model.onnx returns 404).
Install Python 3.12+ and re-run this script:
winget install Python.Python.3.12 --accept-package-agreements --accept-source-agreements
.\scripts\download-tamil-ner-model.ps1 -Python `"`$env:LOCALAPPDATA\Programs\Python\Python312\python.exe`"
Only config.json, ner-labels.txt, and tokenizer.json were saved under models/ta/.
"@
}
Download-HuggingFaceAsset -RelativePath "config.json" -Destination $configPath
Export-LabelsFromConfig -ConfigPath $configPath -LabelsPath $labelsPath
Remove-Item $configPath -Force
foreach ($name in @("sentencepiece.bpe.model", "spiece.model")) {
try {
Download-HuggingFaceAsset -RelativePath $name -Destination (Join-Path $modelsDir $name)
break
}
catch {
continue
}
}
Write-Host ""
Write-Host "Tamil NER model assets saved to $modelsDir"
}
function Download-WithPython {
$pythonExe = Resolve-PythonExecutable -Preferred $Python
if (-not $pythonExe) {
return $false
}
Write-Host "Using Python: $pythonExe"
Write-Host "Repository root: $repoRoot"
Write-Host ""
Push-Location $repoRoot
try {
& $pythonExe $scriptPath
if ($LASTEXITCODE -ne 0) {
throw "Tamil model download script failed with exit code $LASTEXITCODE."
}
}
finally {
Pop-Location
}
return $true
}
Write-Host "Repository root: $repoRoot"
if (-not (Download-WithPython)) {
Write-Host "Python export unavailable; downloading pre-exported ONNX assets from Hugging Face..."
Write-Host ""
Download-WithPowerShell
}

View File

@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Download and export prachuryyaIITG/SampurNER_Tamil_IndicBERTv2 to ONNX for the PII Redaction POC."""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
MODELS_DIR = REPO_ROOT / "models" / "ta"
MODEL_ID = "prachuryyaIITG/SampurNER_Tamil_IndicBERTv2"
REQUIRED_PACKAGES = ("transformers", "optimum[onnxruntime]", "onnx", "torch")
SENTENCEPIECE_CANDIDATES = (
"sentencepiece.bpe.model",
"spiece.model",
"tokenizer.model",
)
TOKENIZER_JSON = "tokenizer.json"
VOCAB_TXT = "vocab.txt"
def ensure_dependencies() -> None:
try:
import optimum.onnxruntime # noqa: F401
import transformers # noqa: F401
except ImportError:
print("Installing Python dependencies (this may take a few minutes)...")
subprocess.check_call(
[sys.executable, "-m", "pip", "install", *REQUIRED_PACKAGES],
stdout=sys.stdout,
stderr=sys.stderr,
)
def copy_sentencepiece_model(source_dir: Path, target_dir: Path) -> Path | None:
for name in SENTENCEPIECE_CANDIDATES:
candidate = source_dir / name
if candidate.exists():
destination = target_dir / name
shutil.copy(candidate, destination)
return destination
return None
def export_wordpiece_assets(source_dir: Path, target_dir: Path) -> list[Path]:
saved: list[Path] = []
tokenizer_json = source_dir / TOKENIZER_JSON
if not tokenizer_json.exists():
return saved
destination = target_dir / TOKENIZER_JSON
shutil.copy(tokenizer_json, destination)
saved.append(destination)
with tokenizer_json.open(encoding="utf-8") as tokenizer_file:
tokenizer_data = json.load(tokenizer_file)
vocab = tokenizer_data.get("model", {}).get("vocab")
if not isinstance(vocab, dict):
return saved
vocab_path = target_dir / VOCAB_TXT
ordered_tokens = [token for token, _ in sorted(vocab.items(), key=lambda item: item[1])]
vocab_path.write_text("\n".join(ordered_tokens), encoding="utf-8")
saved.append(vocab_path)
return saved
def copy_tokenizer_assets(source_dir: Path, target_dir: Path) -> list[Path]:
sentencepiece_path = copy_sentencepiece_model(source_dir, target_dir)
if sentencepiece_path is not None:
return [sentencepiece_path]
wordpiece_assets = export_wordpiece_assets(source_dir, target_dir)
if wordpiece_assets:
print(
"WARNING: Hugging Face repo has no SentencePiece model; saved WordPiece "
f"assets ({', '.join(path.name for path in wordpiece_assets)}). "
"TamilOnnxNerRunner uses vocab.txt (WordPiece) when present, "
"otherwise SentencePiece model files."
)
return wordpiece_assets
raise FileNotFoundError(
f"No tokenizer assets found in {source_dir}. "
f"Expected one of {SENTENCEPIECE_CANDIDATES} or {TOKENIZER_JSON}."
)
def export_model() -> None:
from optimum.onnxruntime import ORTModelForTokenClassification
from transformers import AutoTokenizer
MODELS_DIR.mkdir(parents=True, exist_ok=True)
temp_dir = MODELS_DIR / "_export_temp"
if temp_dir.exists():
shutil.rmtree(temp_dir)
temp_dir.mkdir()
print(f"Exporting {MODEL_ID} to ONNX...")
model = ORTModelForTokenClassification.from_pretrained(MODEL_ID, export=True)
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model.save_pretrained(temp_dir)
tokenizer.save_pretrained(temp_dir)
onnx_files = sorted(temp_dir.glob("*.onnx"))
if not onnx_files:
raise FileNotFoundError("Export completed but no .onnx file was produced.")
target_onnx = MODELS_DIR / "model.onnx"
shutil.copy(onnx_files[0], target_onnx)
config_path = temp_dir / "config.json"
with config_path.open(encoding="utf-8") as config_file:
config = json.load(config_file)
id2label = config.get("id2label", {})
labels = [id2label[str(index)] for index in range(len(id2label))]
labels_path = MODELS_DIR / "ner-labels.txt"
labels_path.write_text("\n".join(labels), encoding="utf-8")
tokenizer_assets = copy_tokenizer_assets(temp_dir, MODELS_DIR)
shutil.rmtree(temp_dir)
print()
print("Tamil NER model assets saved:")
print(f" {target_onnx}")
for asset in tokenizer_assets:
print(f" {asset}")
print(f" {labels_path}")
print()
print("Run from repository root:")
print(" dotnet test --filter Category=TamilNer")
def main() -> int:
ensure_dependencies()
export_model()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,27 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.ML.Tokenizers;
using PiiRedaction.Core.Configuration;
using PiiRedaction.Infrastructure.Onnx;
var modelDir = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "models", "ta"));
if (!Directory.Exists(modelDir))
{
modelDir = Path.GetFullPath("models/ta");
}
var vocabPath = Path.Combine(modelDir, "vocab.txt");
var bertOptions = new BertOptions { LowerCaseBeforeTokenization = false, ApplyBasicTokenization = false };
var tokenizer = BertTokenizer.Create(vocabPath, bertOptions);
var text = "வாடிக்கையாளர் ராஜேஷ் குமார் அழைத்தார்.";
var tokens = tokenizer.EncodeToTokens(text, out _, considerPreTokenization: true, considerNormalization: true);
Console.WriteLine($"count={tokens.Count}");
foreach (var t in tokens) Console.WriteLine($"{t.Id}\t{t.Value}");
var runnerOptions = Options.Create(new PiiRedactionOptions { TamilOnnxModelPath = Path.Combine(modelDir, "model.onnx") });
using var runner = new TamilOnnxNerRunner(runnerOptions, NullLogger<TamilOnnxNerRunner>.Instance);
Console.WriteLine($"Available: {runner.IsModelAvailable}");
foreach (var e in runner.PredictEntities(text))
{
Console.WriteLine($"Entity: '{e.Value}' [{e.StartIndex},{e.Length}]");
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\PiiRedaction.Infrastructure\PiiRedaction.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
</ItemGroup>
</Project>

View File

@@ -31,8 +31,9 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IPiiRedactor, PlaceholderPiiRedactor>();
services.AddSingleton<IPromptSanitizer, PromptSanitizer>();
services.AddSingleton<OnnxNerModelRunner>();
services.AddSingleton<IOnnxNerModelRunner>(provider => provider.GetRequiredService<OnnxNerModelRunner>());
services.AddSingleton<EnglishOnnxNerRunner>();
services.AddSingleton<TamilOnnxNerRunner>();
services.AddSingleton<IOnnxNerModelRunner, RoutingOnnxNerModelRunner>();
services.AddSingleton<IChatClient, MockChatClient>();
services.AddSingleton<ILlmPromptService, MockLlmPromptService>();

View File

@@ -5,6 +5,10 @@ using PiiRedaction.ConsoleApp.Samples;
using PiiRedaction.Core.Abstractions;
using PiiRedaction.Core.Models;
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
Console.InputEncoding = System.Text.Encoding.UTF8;
Console.OutputEncoding = System.Text.Encoding.UTF8;
var interactive = args.Contains("--interactive", StringComparer.OrdinalIgnoreCase);
var listSamples = args.Contains("--list", StringComparer.OrdinalIgnoreCase);

View File

@@ -59,7 +59,7 @@ public sealed class PromptDemoRunner
Console.WriteLine(" dotnet run --project src/PiiRedaction.ConsoleApp -- --sample 2");
Console.WriteLine(" dotnet run --project src/PiiRedaction.ConsoleApp -- --name MrTitlePerson");
Console.WriteLine(" dotnet run --project src/PiiRedaction.ConsoleApp -- --list");
Console.WriteLine(" dotnet run --project src/PiiRedaction.ConsoleApp -- --interactive");
Console.WriteLine(" dotnet run --project src/PiiRedaction.ConsoleApp -- --interactive # UTF-8 input recommended for non-ASCII text");
}
private static void DisplayDetectedEntities(IReadOnlyList<PiiEntity> entities)

View File

@@ -68,6 +68,36 @@ public static class SamplePromptCatalog
"Loan number, customer ID, and account number together.",
"Please verify LN-100200 for CustomerId CID-3000 on AccountNumber ACC-400500."),
new(
"TamilCustomerNameOnly",
"NER (Tamil)",
"Tamil script person name detected via Tamil ONNX NER.",
"வாடிக்கையாளர் ராஜேஷ் குமார் சேமிப்பு கணக்கில் அங்கீகரிக்கப்படாத பரிவர்த்தனைகளைப் புகாரளித்தார்."),
new(
"TamilWithPhonePan",
"NER (Tamil) + Regex",
"Tamil script person plus phone and PAN (regex).",
"வாடிக்கையாளர் ராஜேஷ் குமார் தொலைபேசி 9876543210 PAN ABCDE1234F."),
new(
"TanglishCustomer",
"NER (English/Tanglish)",
"Latin-script Tanglish person name via English ONNX NER.",
"Customer Senthil phone 9876543210 reported a failed UPI transfer."),
new(
"MixedTamilEnglish",
"NER (Mixed)",
"Code-mixed Tamil and English — both script routers may contribute person spans.",
"வாடிக்கையாளர் Ravi Kumar phone 9876543210 disputed the charge."),
new(
"TamilFullFinancial",
"NER (Tamil) + Regex + Domain",
"Tamil person with email, phone, loan number, and PAN (canonical demo in Tamil).",
"வாடிக்கையாளர் ராஜேஷ் குமார் மின்னஞ்சல் ravi.kumar@gmail.com தொலைபேசி 9876543210 LoanNumber LN-456789 PAN ABCDE1234F. இந்த வாடிக்கையாளர் புகாரை சுருக்கமாக கூறுங்கள்."),
new(
"NoPiiCleanTicket",
"Negative",

View File

@@ -1,5 +1,8 @@
{
"PiiRedaction": {
"OnnxModelPath": "models/ner-model.onnx"
"OnnxModelPath": "models/ner-model.onnx",
"EnglishOnnxModelPath": "models/en/ner-model.onnx",
"TamilOnnxModelPath": "models/ta/model.onnx",
"EnableTamilNer": true
}
}

View File

@@ -5,4 +5,10 @@ public sealed class PiiRedactionOptions
public const string SectionName = "PiiRedaction";
public string OnnxModelPath { get; set; } = "models/ner-model.onnx";
public string EnglishOnnxModelPath { get; set; } = "models/en/ner-model.onnx";
public string TamilOnnxModelPath { get; set; } = "models/ta/model.onnx";
public bool EnableTamilNer { get; set; } = true;
}

View File

@@ -0,0 +1,9 @@
namespace PiiRedaction.Core.Detection;
public enum ScriptComposition
{
LatinOnly,
TamilOnly,
Mixed,
NoLetters
}

View File

@@ -0,0 +1,45 @@
namespace PiiRedaction.Core.Detection;
/// <summary>
/// Classifies prompt text by script composition to route NER inference.
/// </summary>
public sealed class ScriptRouter
{
private const char TamilRangeStart = '\u0B80';
private const char TamilRangeEnd = '\u0BFF';
public ScriptComposition GetComposition(string text)
{
ArgumentNullException.ThrowIfNull(text);
var hasLatin = false;
var hasTamil = false;
foreach (var character in text)
{
if (IsTamilLetter(character))
{
hasTamil = true;
}
else if (char.IsAsciiLetter(character))
{
hasLatin = true;
}
if (hasLatin && hasTamil)
{
return ScriptComposition.Mixed;
}
}
if (!hasLatin && !hasTamil)
{
return ScriptComposition.NoLetters;
}
return hasTamil ? ScriptComposition.TamilOnly : ScriptComposition.LatinOnly;
}
internal static bool IsTamilLetter(char character) =>
character is >= TamilRangeStart and <= TamilRangeEnd;
}

View File

@@ -0,0 +1,107 @@
using Microsoft.Extensions.Logging;
using Microsoft.ML.Tokenizers;
namespace PiiRedaction.Infrastructure.Onnx;
public sealed class BertWordPieceEncoder : ITokenClassifierEncoder
{
private readonly BertTokenizer? _tokenizer;
private readonly ILogger _logger;
public BertWordPieceEncoder(string modelDirectory, ILogger logger)
{
_logger = logger;
_tokenizer = TryLoadTokenizer(modelDirectory);
}
public bool IsAvailable => _tokenizer is not null;
public EncodedSequence? Encode(string text, int maxSequenceLength)
{
if (_tokenizer is null)
{
return null;
}
var encodedTokens = _tokenizer.EncodeToTokens(text, out _, considerPreTokenization: true, considerNormalization: true);
var wordTokens = encodedTokens.Take(Math.Max(0, maxSequenceLength - 2)).ToList();
if (wordTokens.Count == 0)
{
return null;
}
var sequenceLength = wordTokens.Count + 2;
var inputIds = new long[sequenceLength];
var attentionMask = new long[sequenceLength];
var tokenTypeIds = new long[sequenceLength];
var offsets = new (int Start, int End)[sequenceLength];
var tokenIds = new int[sequenceLength];
inputIds[0] = _tokenizer.ClassificationTokenId;
attentionMask[0] = 1;
tokenIds[0] = _tokenizer.ClassificationTokenId;
offsets[0] = (0, 0);
for (var i = 0; i < wordTokens.Count; i++)
{
var token = wordTokens[i];
var index = i + 1;
inputIds[index] = token.Id;
attentionMask[index] = 1;
tokenIds[index] = token.Id;
offsets[index] = ToCharOffsets(token.Offset, text.Length);
}
inputIds[sequenceLength - 1] = _tokenizer.SeparatorTokenId;
attentionMask[sequenceLength - 1] = 1;
tokenIds[sequenceLength - 1] = _tokenizer.SeparatorTokenId;
offsets[sequenceLength - 1] = (0, 0);
return new EncodedSequence(inputIds, attentionMask, tokenTypeIds, offsets, tokenIds, sequenceLength);
}
public bool IsSpecialToken(int tokenId) =>
_tokenizer is not null &&
(tokenId == _tokenizer.ClassificationTokenId ||
tokenId == _tokenizer.SeparatorTokenId ||
tokenId == _tokenizer.PaddingTokenId);
private BertTokenizer? TryLoadTokenizer(string modelDirectory)
{
var vocabPath = OnnxAssetPathResolver.ResolveAssetPath(Path.Combine(modelDirectory, "vocab.txt"));
if (!File.Exists(vocabPath))
{
_logger.LogWarning("Tokenizer vocabulary not found at {VocabPath}.", vocabPath);
return null;
}
try
{
return BertTokenizer.Create(vocabPath, CreateBertOptions(modelDirectory));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load BERT tokenizer from {VocabPath}.", vocabPath);
return null;
}
}
private static BertOptions CreateBertOptions(string modelDirectory)
{
var tokenizerJsonPath = OnnxAssetPathResolver.ResolveAssetPath(
Path.Combine(modelDirectory, "tokenizer.json"));
var whitespaceOnlyPretokenization = File.Exists(tokenizerJsonPath);
return new BertOptions
{
LowerCaseBeforeTokenization = false,
ApplyBasicTokenization = !whitespaceOnlyPretokenization
};
}
private static (int Start, int End) ToCharOffsets(Range offset, int textLength)
{
var (start, length) = offset.GetOffsetAndLength(textLength);
return (start, start + length);
}
}

View File

@@ -0,0 +1,35 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PiiRedaction.Core.Configuration;
using PiiRedaction.Core.Detection;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Infrastructure.Onnx;
public class EnglishOnnxNerRunner : IOnnxNerModelRunner, IDisposable
{
private readonly OnnxTokenClassifierRunner _runner;
protected EnglishOnnxNerRunner(IOptions<PiiRedactionOptions> options, ILogger logger)
{
var modelPath = OnnxAssetPathResolver.ResolveModelPath(
options.Value.EnglishOnnxModelPath,
options.Value.OnnxModelPath);
var modelDirectory = Path.GetDirectoryName(modelPath) ?? Environment.CurrentDirectory;
var labels = OnnxAssetPathResolver.LoadLabels(modelDirectory);
var encoder = new BertWordPieceEncoder(modelDirectory, logger);
_runner = new OnnxTokenClassifierRunner(modelPath, encoder, NerLabelConfig.English, labels, logger);
}
public EnglishOnnxNerRunner(IOptions<PiiRedactionOptions> options, ILogger<EnglishOnnxNerRunner> logger)
: this(options, (ILogger)logger)
{
}
public bool IsModelAvailable => _runner.IsAvailable;
public IReadOnlyList<PiiEntity> PredictEntities(string text) => _runner.PredictEntities(text);
public void Dispose() => _runner.Dispose();
}

View File

@@ -0,0 +1,18 @@
namespace PiiRedaction.Infrastructure.Onnx;
public sealed record EncodedSequence(
long[] InputIds,
long[] AttentionMask,
long[] TokenTypeIds,
(int Start, int End)[] Offsets,
int[] TokenIds,
int SequenceLength);
public interface ITokenClassifierEncoder
{
bool IsAvailable { get; }
EncodedSequence? Encode(string text, int maxSequenceLength);
bool IsSpecialToken(int tokenId);
}

View File

@@ -0,0 +1,26 @@
namespace PiiRedaction.Infrastructure.Onnx;
public sealed class NerLabelConfig
{
private readonly Func<string, bool> _isPersonLabel;
private NerLabelConfig(Func<string, bool> isPersonLabel) => _isPersonLabel = isPersonLabel;
public static NerLabelConfig English { get; } = new(IsEnglishPersonLabel);
public static NerLabelConfig Tamil { get; } = new(IsTamilPersonLabel);
public bool IsPersonLabel(string label) => _isPersonLabel(label);
public bool IsBeginLabel(string label) => label.StartsWith("B-", StringComparison.Ordinal);
public bool IsInsideLabel(string label) => label.StartsWith("I-", StringComparison.Ordinal);
private static bool IsEnglishPersonLabel(string label) =>
label is "B-PER" or "I-PER" or "B-PERSON" or "I-PERSON"
|| (label.EndsWith("-PER", StringComparison.Ordinal) &&
(label.StartsWith("B-", StringComparison.Ordinal) || label.StartsWith("I-", StringComparison.Ordinal)));
private static bool IsTamilPersonLabel(string label) =>
label.Contains("person", StringComparison.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,53 @@
namespace PiiRedaction.Infrastructure.Onnx;
public static class OnnxAssetPathResolver
{
public static string ResolveAssetPath(string configuredPath)
{
if (Path.IsPathRooted(configuredPath) && File.Exists(configuredPath))
{
return configuredPath;
}
var directory = new DirectoryInfo(Environment.CurrentDirectory);
while (directory is not null)
{
var candidate = Path.GetFullPath(Path.Combine(directory.FullName, configuredPath));
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
return Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, configuredPath));
}
public static string ResolveModelPath(string primaryPath, params string[] fallbackPaths)
{
foreach (var candidatePath in new[] { primaryPath }.Concat(fallbackPaths))
{
var resolved = ResolveAssetPath(candidatePath);
if (File.Exists(resolved))
{
return resolved;
}
}
return ResolveAssetPath(primaryPath);
}
public static string[] LoadLabels(string modelDirectory)
{
var labelsPath = ResolveAssetPath(Path.Combine(modelDirectory, "ner-labels.txt"));
if (!File.Exists(labelsPath))
{
return [];
}
return File.ReadAllLines(labelsPath)
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToArray();
}
}

View File

@@ -1,325 +1,16 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using Microsoft.ML.Tokenizers;
using PiiRedaction.Core.Configuration;
using PiiRedaction.Core.Detection;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Infrastructure.Onnx;
/// <summary>
/// Wraps ONNX Runtime inference for NER models.
/// Tokenization and tensor preparation are isolated here so detectors remain model-agnostic.
/// Backward-compatible alias for <see cref="EnglishOnnxNerRunner"/>.
/// </summary>
public sealed class OnnxNerModelRunner : IOnnxNerModelRunner, IDisposable
public sealed class OnnxNerModelRunner : EnglishOnnxNerRunner
{
private const int MaxSequenceLength = 128;
private readonly ILogger<OnnxNerModelRunner> _logger;
private readonly string _modelPath;
private readonly BertTokenizer? _tokenizer;
private readonly string[] _labels;
private InferenceSession? _session;
public OnnxNerModelRunner(IOptions<PiiRedactionOptions> options, ILogger<OnnxNerModelRunner> logger)
: base(options, logger)
{
_logger = logger;
_modelPath = ResolveAssetPath(options.Value.OnnxModelPath);
var modelDirectory = Path.GetDirectoryName(_modelPath) ?? Environment.CurrentDirectory;
_labels = LoadLabels(modelDirectory);
_tokenizer = TryLoadTokenizer(modelDirectory);
_session = TryCreateSession();
}
public bool IsModelAvailable => _session is not null && _tokenizer is not null && _labels.Length > 0;
public IReadOnlyList<PiiEntity> PredictEntities(string text)
{
if (!IsModelAvailable || _session is null || _tokenizer is null)
{
return [];
}
var encodedTokens = _tokenizer.EncodeToTokens(text, out _, considerPreTokenization: true, considerNormalization: true);
var wordTokens = encodedTokens.Take(Math.Max(0, MaxSequenceLength - 2)).ToList();
if (wordTokens.Count == 0)
{
return [];
}
var sequenceLength = wordTokens.Count + 2;
var inputIds = new long[sequenceLength];
var attentionMask = new long[sequenceLength];
var tokenTypeIds = new long[sequenceLength];
var offsets = new (int Start, int End)[sequenceLength];
var tokenIds = new int[sequenceLength];
inputIds[0] = _tokenizer.ClassificationTokenId;
attentionMask[0] = 1;
tokenIds[0] = _tokenizer.ClassificationTokenId;
offsets[0] = (0, 0);
for (var i = 0; i < wordTokens.Count; i++)
{
var token = wordTokens[i];
var index = i + 1;
inputIds[index] = token.Id;
attentionMask[index] = 1;
tokenIds[index] = token.Id;
offsets[index] = ToCharOffsets(token.Offset, text.Length);
}
inputIds[sequenceLength - 1] = _tokenizer.SeparatorTokenId;
attentionMask[sequenceLength - 1] = 1;
tokenIds[sequenceLength - 1] = _tokenizer.SeparatorTokenId;
offsets[sequenceLength - 1] = (0, 0);
var predictedLabelIds = RunInference(inputIds, attentionMask, tokenTypeIds, sequenceLength);
return DecodePersonEntities(text, predictedLabelIds, offsets, tokenIds, sequenceLength);
}
private int[] RunInference(long[] inputIds, long[] attentionMask, long[] tokenTypeIds, int sequenceLength)
{
var inputIdsTensor = CreateTensor(inputIds, sequenceLength);
var attentionMaskTensor = CreateTensor(attentionMask, sequenceLength);
var inputs = new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor(_session!.InputMetadata.Keys.First(key => key.Contains("input_ids", StringComparison.OrdinalIgnoreCase)), inputIdsTensor),
NamedOnnxValue.CreateFromTensor(_session.InputMetadata.Keys.First(key => key.Contains("attention_mask", StringComparison.OrdinalIgnoreCase)), attentionMaskTensor)
};
var tokenTypeInputName = _session.InputMetadata.Keys.FirstOrDefault(key => key.Contains("token_type", StringComparison.OrdinalIgnoreCase));
if (tokenTypeInputName is not null)
{
inputs.Add(NamedOnnxValue.CreateFromTensor(tokenTypeInputName, CreateTensor(tokenTypeIds, sequenceLength)));
}
using var results = _session.Run(inputs);
var outputName = _session.OutputMetadata.Keys.FirstOrDefault(key =>
key.Contains("logits", StringComparison.OrdinalIgnoreCase))
?? results.First().Name;
var logits = results.First(result => result.Name == outputName).AsTensor<float>();
var numLabels = _labels.Length;
var predictions = new int[sequenceLength];
for (var tokenIndex = 0; tokenIndex < sequenceLength; tokenIndex++)
{
var bestLabel = 0;
var bestScore = float.MinValue;
for (var labelIndex = 0; labelIndex < numLabels; labelIndex++)
{
var score = logits[0, tokenIndex, labelIndex];
if (score > bestScore)
{
bestScore = score;
bestLabel = labelIndex;
}
}
predictions[tokenIndex] = bestLabel;
}
return predictions;
}
private static DenseTensor<long> CreateTensor(long[] values, int sequenceLength)
{
var tensor = new DenseTensor<long>([1, sequenceLength]);
for (var i = 0; i < sequenceLength; i++)
{
tensor[0, i] = values[i];
}
return tensor;
}
private IReadOnlyList<PiiEntity> DecodePersonEntities(
string text,
int[] predictedLabelIds,
(int Start, int End)[] offsets,
int[] tokenIds,
int sequenceLength)
{
var entities = new List<PiiEntity>();
int? entityStart = null;
int? entityEnd = null;
void FlushEntity()
{
if (!entityStart.HasValue || !entityEnd.HasValue || entityEnd.Value <= entityStart.Value)
{
entityStart = null;
entityEnd = null;
return;
}
var value = text[entityStart.Value..entityEnd.Value];
if (!string.IsNullOrWhiteSpace(value))
{
entities.Add(new PiiEntity(
PiiEntityType.Person,
value,
entityStart.Value,
entityEnd.Value - entityStart.Value,
PiiDetectionSource.Ner));
}
entityStart = null;
entityEnd = null;
}
for (var i = 0; i < sequenceLength; i++)
{
if (IsSpecialToken(tokenIds[i]))
{
FlushEntity();
continue;
}
var label = _labels[predictedLabelIds[i]];
var (start, end) = offsets[i];
var hasOffset = end > start;
if (!IsPersonLabel(label))
{
FlushEntity();
continue;
}
if (label.StartsWith("B-", StringComparison.Ordinal))
{
FlushEntity();
if (hasOffset)
{
entityStart = start;
entityEnd = end;
}
}
else if (label.StartsWith("I-", StringComparison.Ordinal))
{
if (!entityStart.HasValue && hasOffset)
{
entityStart = start;
entityEnd = end;
}
else if (hasOffset)
{
entityEnd = Math.Max(entityEnd ?? end, end);
}
}
}
FlushEntity();
return entities;
}
private bool IsSpecialToken(int tokenId) =>
tokenId == _tokenizer!.ClassificationTokenId ||
tokenId == _tokenizer.SeparatorTokenId ||
tokenId == _tokenizer.PaddingTokenId;
private static (int Start, int End) ToCharOffsets(Range offset, int textLength)
{
var (start, length) = offset.GetOffsetAndLength(textLength);
return (start, start + length);
}
private static bool IsPersonLabel(string label) =>
label is "B-PER" or "I-PER" or "B-PERSON" or "I-PERSON"
|| (label.EndsWith("-PER", StringComparison.Ordinal) &&
(label.StartsWith("B-", StringComparison.Ordinal) || label.StartsWith("I-", StringComparison.Ordinal)));
private InferenceSession? TryCreateSession()
{
if (!File.Exists(_modelPath))
{
_logger.LogWarning(
"ONNX NER model not found at {ModelPath}. Person-name detection will return no results.",
_modelPath);
return null;
}
if (_tokenizer is null || _labels.Length == 0)
{
_logger.LogWarning(
"Tokenizer vocabulary or label map missing for ONNX NER model at {ModelPath}.",
_modelPath);
return null;
}
try
{
var session = new InferenceSession(_modelPath);
_logger.LogInformation("ONNX NER model loaded from {ModelPath}.", _modelPath);
return session;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load ONNX NER model from {ModelPath}.", _modelPath);
return null;
}
}
private BertTokenizer? TryLoadTokenizer(string modelDirectory)
{
var vocabPath = ResolveAssetPath(Path.Combine(modelDirectory, "vocab.txt"));
if (!File.Exists(vocabPath))
{
_logger.LogWarning("Tokenizer vocabulary not found at {VocabPath}.", vocabPath);
return null;
}
try
{
return BertTokenizer.Create(vocabPath, new BertOptions
{
LowerCaseBeforeTokenization = false
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load BERT tokenizer from {VocabPath}.", vocabPath);
return null;
}
}
private static string[] LoadLabels(string modelDirectory)
{
var labelsPath = ResolveAssetPath(Path.Combine(modelDirectory, "ner-labels.txt"));
if (!File.Exists(labelsPath))
{
return [];
}
return File.ReadAllLines(labelsPath)
.Where(line => !string.IsNullOrWhiteSpace(line))
.ToArray();
}
internal static string ResolveAssetPath(string configuredPath)
{
if (Path.IsPathRooted(configuredPath) && File.Exists(configuredPath))
{
return configuredPath;
}
var directory = new DirectoryInfo(Environment.CurrentDirectory);
while (directory is not null)
{
var candidate = Path.GetFullPath(Path.Combine(directory.FullName, configuredPath));
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
return Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, configuredPath));
}
public void Dispose() => _session?.Dispose();
}

View File

@@ -0,0 +1,229 @@
using Microsoft.Extensions.Logging;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Infrastructure.Onnx;
/// <summary>
/// Shared ONNX token-classification inference and BIO decoding for NER models.
/// </summary>
public sealed class OnnxTokenClassifierRunner : IDisposable
{
private const int MaxSequenceLength = 128;
private readonly ITokenClassifierEncoder _encoder;
private readonly NerLabelConfig _labelConfig;
private readonly string[] _labels;
private readonly ILogger _logger;
private readonly string _modelPath;
private InferenceSession? _session;
public OnnxTokenClassifierRunner(
string modelPath,
ITokenClassifierEncoder encoder,
NerLabelConfig labelConfig,
string[] labels,
ILogger logger)
{
_modelPath = modelPath;
_encoder = encoder;
_labelConfig = labelConfig;
_labels = labels;
_logger = logger;
_session = TryCreateSession();
}
public bool IsAvailable => _session is not null && _encoder.IsAvailable && _labels.Length > 0;
public IReadOnlyList<PiiEntity> PredictEntities(string text)
{
if (!IsAvailable || _session is null)
{
return [];
}
var encoded = _encoder.Encode(text, MaxSequenceLength);
if (encoded is null)
{
return [];
}
var predictedLabelIds = RunInference(encoded);
return DecodePersonEntities(text, predictedLabelIds, encoded);
}
private int[] RunInference(EncodedSequence encoded)
{
var inputIdsTensor = CreateTensor(encoded.InputIds, encoded.SequenceLength);
var attentionMaskTensor = CreateTensor(encoded.AttentionMask, encoded.SequenceLength);
var inputs = new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor(
_session!.InputMetadata.Keys.First(key => key.Contains("input_ids", StringComparison.OrdinalIgnoreCase)),
inputIdsTensor),
NamedOnnxValue.CreateFromTensor(
_session.InputMetadata.Keys.First(key => key.Contains("attention_mask", StringComparison.OrdinalIgnoreCase)),
attentionMaskTensor)
};
var tokenTypeInputName = _session.InputMetadata.Keys.FirstOrDefault(key =>
key.Contains("token_type", StringComparison.OrdinalIgnoreCase));
if (tokenTypeInputName is not null)
{
inputs.Add(NamedOnnxValue.CreateFromTensor(
tokenTypeInputName,
CreateTensor(encoded.TokenTypeIds, encoded.SequenceLength)));
}
using var results = _session.Run(inputs);
var outputName = _session.OutputMetadata.Keys.FirstOrDefault(key =>
key.Contains("logits", StringComparison.OrdinalIgnoreCase))
?? results.First().Name;
var logits = results.First(result => result.Name == outputName).AsTensor<float>();
var numLabels = _labels.Length;
var predictions = new int[encoded.SequenceLength];
for (var tokenIndex = 0; tokenIndex < encoded.SequenceLength; tokenIndex++)
{
var bestLabel = 0;
var bestScore = float.MinValue;
for (var labelIndex = 0; labelIndex < numLabels; labelIndex++)
{
var score = logits[0, tokenIndex, labelIndex];
if (score > bestScore)
{
bestScore = score;
bestLabel = labelIndex;
}
}
predictions[tokenIndex] = bestLabel;
}
return predictions;
}
private static DenseTensor<long> CreateTensor(long[] values, int sequenceLength)
{
var tensor = new DenseTensor<long>([1, sequenceLength]);
for (var i = 0; i < sequenceLength; i++)
{
tensor[0, i] = values[i];
}
return tensor;
}
private IReadOnlyList<PiiEntity> DecodePersonEntities(
string text,
int[] predictedLabelIds,
EncodedSequence encoded)
{
var entities = new List<PiiEntity>();
int? entityStart = null;
int? entityEnd = null;
void FlushEntity()
{
if (!entityStart.HasValue || !entityEnd.HasValue || entityEnd.Value <= entityStart.Value)
{
entityStart = null;
entityEnd = null;
return;
}
var value = text[entityStart.Value..entityEnd.Value];
if (!string.IsNullOrWhiteSpace(value))
{
entities.Add(new PiiEntity(
PiiEntityType.Person,
value,
entityStart.Value,
entityEnd.Value - entityStart.Value,
PiiDetectionSource.Ner));
}
entityStart = null;
entityEnd = null;
}
for (var i = 0; i < encoded.SequenceLength; i++)
{
if (_encoder.IsSpecialToken(encoded.TokenIds[i]))
{
FlushEntity();
continue;
}
var label = _labels[predictedLabelIds[i]];
var (start, end) = encoded.Offsets[i];
var hasOffset = end > start;
if (!_labelConfig.IsPersonLabel(label))
{
FlushEntity();
continue;
}
if (_labelConfig.IsBeginLabel(label))
{
FlushEntity();
if (hasOffset)
{
entityStart = start;
entityEnd = end;
}
}
else if (_labelConfig.IsInsideLabel(label))
{
if (!entityStart.HasValue && hasOffset)
{
entityStart = start;
entityEnd = end;
}
else if (hasOffset)
{
entityEnd = Math.Max(entityEnd ?? end, end);
}
}
}
FlushEntity();
return entities;
}
private InferenceSession? TryCreateSession()
{
if (!File.Exists(_modelPath))
{
_logger.LogWarning(
"ONNX NER model not found at {ModelPath}. Person-name detection will return no results.",
_modelPath);
return null;
}
if (!_encoder.IsAvailable || _labels.Length == 0)
{
_logger.LogWarning(
"Tokenizer or label map missing for ONNX NER model at {ModelPath}.",
_modelPath);
return null;
}
try
{
var session = new InferenceSession(_modelPath);
_logger.LogInformation("ONNX NER model loaded from {ModelPath}.", _modelPath);
return session;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load ONNX NER model from {ModelPath}.", _modelPath);
return null;
}
}
public void Dispose() => _session?.Dispose();
}

View File

@@ -0,0 +1,105 @@
using Microsoft.Extensions.Options;
using PiiRedaction.Core.Configuration;
using PiiRedaction.Core.Detection;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Infrastructure.Onnx;
/// <summary>
/// Routes NER inference to English and/or Tamil ONNX models based on script composition.
/// </summary>
public sealed class RoutingOnnxNerModelRunner : IOnnxNerModelRunner
{
private readonly ScriptRouter _scriptRouter = new();
private readonly IOnnxNerModelRunner _englishRunner;
private readonly IOnnxNerModelRunner _tamilRunner;
private readonly bool _enableTamilNer;
public RoutingOnnxNerModelRunner(
EnglishOnnxNerRunner englishRunner,
TamilOnnxNerRunner tamilRunner,
IOptions<PiiRedactionOptions> options)
: this(englishRunner, tamilRunner, options.Value.EnableTamilNer)
{
}
internal RoutingOnnxNerModelRunner(
IOnnxNerModelRunner englishRunner,
IOnnxNerModelRunner tamilRunner,
bool enableTamilNer)
{
_englishRunner = englishRunner;
_tamilRunner = tamilRunner;
_enableTamilNer = enableTamilNer;
}
public bool IsModelAvailable =>
_englishRunner.IsModelAvailable || (_enableTamilNer && _tamilRunner.IsModelAvailable);
public IReadOnlyList<PiiEntity> PredictEntities(string text)
{
var composition = _scriptRouter.GetComposition(text);
var entities = new List<PiiEntity>();
switch (composition)
{
case ScriptComposition.LatinOnly:
if (_englishRunner.IsModelAvailable)
{
entities.AddRange(_englishRunner.PredictEntities(text));
}
break;
case ScriptComposition.TamilOnly:
if (_enableTamilNer && _tamilRunner.IsModelAvailable)
{
entities.AddRange(_tamilRunner.PredictEntities(text));
}
break;
case ScriptComposition.Mixed:
if (_englishRunner.IsModelAvailable)
{
entities.AddRange(_englishRunner.PredictEntities(text));
}
if (_enableTamilNer && _tamilRunner.IsModelAvailable)
{
entities.AddRange(_tamilRunner.PredictEntities(text));
}
break;
case ScriptComposition.NoLetters:
break;
}
return MergePersonSpans(entities);
}
internal static IReadOnlyList<PiiEntity> MergePersonSpans(IReadOnlyList<PiiEntity> entities)
{
if (entities.Count <= 1)
{
return entities;
}
var accepted = new List<PiiEntity>();
foreach (var candidate in entities.OrderByDescending(entity => entity.Length).ThenBy(entity => entity.StartIndex))
{
if (accepted.Any(existing => Overlaps(existing, candidate)))
{
continue;
}
accepted.Add(candidate);
}
return accepted.OrderBy(entity => entity.StartIndex).ToList();
}
private static bool Overlaps(PiiEntity left, PiiEntity right) =>
left.StartIndex < right.EndIndex && right.StartIndex < left.EndIndex;
}

View File

@@ -0,0 +1,102 @@
using Microsoft.Extensions.Logging;
using Microsoft.ML.Tokenizers;
namespace PiiRedaction.Infrastructure.Onnx;
public sealed class SentencePieceEncoder : ITokenClassifierEncoder
{
private readonly SentencePieceTokenizer? _tokenizer;
private readonly ILogger _logger;
public SentencePieceEncoder(string modelDirectory, ILogger logger)
{
_logger = logger;
_tokenizer = TryLoadTokenizer(modelDirectory);
}
public bool IsAvailable => _tokenizer is not null;
public EncodedSequence? Encode(string text, int maxSequenceLength)
{
if (_tokenizer is null)
{
return null;
}
var encodedTokens = _tokenizer.EncodeToTokens(text, out _, considerPreTokenization: true, considerNormalization: true);
var wordTokens = encodedTokens.Take(Math.Max(0, maxSequenceLength - 2)).ToList();
if (wordTokens.Count == 0)
{
return null;
}
var sequenceLength = wordTokens.Count + 2;
var inputIds = new long[sequenceLength];
var attentionMask = new long[sequenceLength];
var tokenTypeIds = new long[sequenceLength];
var offsets = new (int Start, int End)[sequenceLength];
var tokenIds = new int[sequenceLength];
inputIds[0] = _tokenizer.BeginningOfSentenceId;
attentionMask[0] = 1;
tokenIds[0] = _tokenizer.BeginningOfSentenceId;
offsets[0] = (0, 0);
for (var i = 0; i < wordTokens.Count; i++)
{
var token = wordTokens[i];
var index = i + 1;
inputIds[index] = token.Id;
attentionMask[index] = 1;
tokenIds[index] = token.Id;
offsets[index] = ToCharOffsets(token.Offset, text.Length);
}
inputIds[sequenceLength - 1] = _tokenizer.EndOfSentenceId;
attentionMask[sequenceLength - 1] = 1;
tokenIds[sequenceLength - 1] = _tokenizer.EndOfSentenceId;
offsets[sequenceLength - 1] = (0, 0);
return new EncodedSequence(inputIds, attentionMask, tokenTypeIds, offsets, tokenIds, sequenceLength);
}
public bool IsSpecialToken(int tokenId) =>
_tokenizer is not null &&
(tokenId == _tokenizer.BeginningOfSentenceId ||
tokenId == _tokenizer.EndOfSentenceId ||
tokenId == _tokenizer.UnknownId);
private SentencePieceTokenizer? TryLoadTokenizer(string modelDirectory)
{
foreach (var fileName in new[] { "sentencepiece.bpe.model", "spiece.model", "tokenizer.model" })
{
var modelPath = OnnxAssetPathResolver.ResolveAssetPath(Path.Combine(modelDirectory, fileName));
if (!File.Exists(modelPath))
{
continue;
}
try
{
using var stream = File.OpenRead(modelPath);
return SentencePieceTokenizer.Create(stream, addBeginningOfSentence: false, addEndOfSentence: false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load SentencePiece tokenizer from {ModelPath}.", modelPath);
return null;
}
}
_logger.LogWarning(
"SentencePiece model not found in {ModelDirectory}. Expected sentencepiece.bpe.model or spiece.model.",
modelDirectory);
return null;
}
private static (int Start, int End) ToCharOffsets(Range offset, int textLength)
{
var (start, length) = offset.GetOffsetAndLength(textLength);
return (start, start + length);
}
}

View File

@@ -0,0 +1,27 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PiiRedaction.Core.Configuration;
using PiiRedaction.Core.Detection;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Infrastructure.Onnx;
public sealed class TamilOnnxNerRunner : IOnnxNerModelRunner, IDisposable
{
private readonly OnnxTokenClassifierRunner _runner;
public TamilOnnxNerRunner(IOptions<PiiRedactionOptions> options, ILogger<TamilOnnxNerRunner> logger)
{
var modelPath = OnnxAssetPathResolver.ResolveModelPath(options.Value.TamilOnnxModelPath);
var modelDirectory = Path.GetDirectoryName(modelPath) ?? Environment.CurrentDirectory;
var labels = OnnxAssetPathResolver.LoadLabels(modelDirectory);
var encoder = TokenClassifierEncoderFactory.Create(modelDirectory, logger);
_runner = new OnnxTokenClassifierRunner(modelPath, encoder, NerLabelConfig.Tamil, labels, logger);
}
public bool IsModelAvailable => _runner.IsAvailable;
public IReadOnlyList<PiiEntity> PredictEntities(string text) => _runner.PredictEntities(text);
public void Dispose() => _runner.Dispose();
}

View File

@@ -0,0 +1,40 @@
using Microsoft.Extensions.Logging;
namespace PiiRedaction.Infrastructure.Onnx;
public static class TokenClassifierEncoderFactory
{
private static readonly string[] SentencePieceFileNames =
["sentencepiece.bpe.model", "spiece.model", "tokenizer.model"];
public static ITokenClassifierEncoder Create(string modelDirectory, ILogger logger)
{
var vocabPath = OnnxAssetPathResolver.ResolveAssetPath(Path.Combine(modelDirectory, "vocab.txt"));
if (File.Exists(vocabPath))
{
logger.LogInformation(
"Using WordPiece tokenizer (vocab.txt) from {ModelDirectory}.",
modelDirectory);
return new BertWordPieceEncoder(modelDirectory, logger);
}
foreach (var fileName in SentencePieceFileNames)
{
var sentencePiecePath = OnnxAssetPathResolver.ResolveAssetPath(
Path.Combine(modelDirectory, fileName));
if (File.Exists(sentencePiecePath))
{
logger.LogInformation(
"Using SentencePiece tokenizer ({FileName}) from {ModelDirectory}.",
fileName,
modelDirectory);
return new SentencePieceEncoder(modelDirectory, logger);
}
}
logger.LogWarning(
"No tokenizer assets found in {ModelDirectory}. Expected vocab.txt or a SentencePiece model file.",
modelDirectory);
return new BertWordPieceEncoder(modelDirectory, logger);
}
}

View File

@@ -19,4 +19,8 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="PiiRedaction.Infrastructure.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,17 @@
<Application x:Class="PiiRedaction.TestHarness.Wpf.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:PiiRedaction.TestHarness.Wpf.Converters">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Resources/Styles.xaml" />
</ResourceDictionary.MergedDictionaries>
<converters:ScriptCompositionToBrushConverter x:Key="ScriptCompositionToBrushConverter" />
<converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<converters:PassFailBrushConverter x:Key="PassFailBrushConverter" />
<converters:StringNotEmptyToVisibilityConverter x:Key="StringNotEmptyToVisibilityConverter" />
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,57 @@
using System.IO;
using System.Windows;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using PiiRedaction.TestHarness.Wpf.DependencyInjection;
using PiiRedaction.TestHarness.Wpf.Services;
using PiiRedaction.TestHarness.Wpf.ViewModels;
namespace PiiRedaction.TestHarness.Wpf;
public partial class App : Application
{
private IHost? _host;
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
_host = Host.CreateDefaultBuilder()
.ConfigureAppConfiguration((_, configuration) =>
{
configuration.SetBasePath(AppContext.BaseDirectory);
configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
configuration.AddEnvironmentVariables();
})
.ConfigureServices((context, services) =>
{
services.AddPiiRedactionServices(context.Configuration);
services.AddSingleton<ITestPromptCatalog, TestPromptCatalog>();
services.AddSingleton<IRedactionAppService, RedactionAppService>();
services.AddSingleton<IScriptAnalysisService, ScriptAnalysisService>();
services.AddSingleton<IModelStatusService, ModelStatusService>();
services.AddSingleton<MainViewModel>();
services.AddSingleton<MainWindow>();
})
.Build();
Directory.SetCurrentDirectory(AppContext.BaseDirectory);
await _host.StartAsync().ConfigureAwait(true);
var mainWindow = _host.Services.GetRequiredService<MainWindow>();
mainWindow.Show();
}
protected override async void OnExit(ExitEventArgs e)
{
if (_host is not null)
{
await _host.StopAsync().ConfigureAwait(true);
_host.Dispose();
}
base.OnExit(e);
}
}

View File

@@ -0,0 +1,71 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
using PiiRedaction.Core.Detection;
namespace PiiRedaction.TestHarness.Wpf.Converters;
public sealed class ScriptCompositionToBrushConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not ScriptComposition composition)
{
return Brushes.Gray;
}
return composition switch
{
ScriptComposition.LatinOnly => new SolidColorBrush(Color.FromRgb(37, 99, 235)),
ScriptComposition.TamilOnly => new SolidColorBrush(Color.FromRgb(124, 58, 237)),
ScriptComposition.Mixed => new SolidColorBrush(Color.FromRgb(217, 119, 6)),
ScriptComposition.NoLetters => new SolidColorBrush(Color.FromRgb(107, 114, 128)),
_ => Brushes.Gray
};
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
public sealed class BoolToVisibilityConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is true ? Visibility.Visible : Visibility.Collapsed;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
public sealed class StringNotEmptyToVisibilityConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is string text && !string.IsNullOrWhiteSpace(text)
? Visibility.Visible
: Visibility.Collapsed;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
public sealed class PassFailBrushConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is true)
{
return new SolidColorBrush(Color.FromRgb(22, 163, 74));
}
if (value is false)
{
return new SolidColorBrush(Color.FromRgb(220, 38, 38));
}
return Brushes.Gray;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
}

View File

@@ -0,0 +1,42 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.AI;
using PiiRedaction.Core.Abstractions;
using PiiRedaction.Core.Configuration;
using PiiRedaction.Core.Detection;
using PiiRedaction.Core.Redaction;
using PiiRedaction.Core.Sanitization;
using PiiRedaction.Infrastructure.Llm;
using PiiRedaction.Infrastructure.Onnx;
namespace PiiRedaction.TestHarness.Wpf.DependencyInjection;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddPiiRedactionServices(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<PiiRedactionOptions>(configuration.GetSection(PiiRedactionOptions.SectionName));
services.AddSingleton<DomainRulePiiDetector>();
services.AddSingleton<RegexPiiDetector>();
services.AddSingleton<OnnxNerPiiDetector>();
services.AddSingleton<IPiiDetector>(provider => new CompositePiiDetector(
[
provider.GetRequiredService<DomainRulePiiDetector>(),
provider.GetRequiredService<RegexPiiDetector>(),
provider.GetRequiredService<OnnxNerPiiDetector>()
]));
services.AddSingleton<IPiiRedactor, PlaceholderPiiRedactor>();
services.AddSingleton<IPromptSanitizer, PromptSanitizer>();
services.AddSingleton<EnglishOnnxNerRunner>();
services.AddSingleton<TamilOnnxNerRunner>();
services.AddSingleton<IOnnxNerModelRunner, RoutingOnnxNerModelRunner>();
services.AddSingleton<IChatClient, MockChatClient>();
services.AddSingleton<ILlmPromptService, MockLlmPromptService>();
return services;
}
}

View File

@@ -0,0 +1,311 @@
<Window x:Class="PiiRedaction.TestHarness.Wpf.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="PII Redaction Test Harness"
Height="920"
Width="1520"
MinHeight="720"
MinWidth="1200"
Background="{StaticResource AppBackgroundBrush}">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" MinHeight="320" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Status bar -->
<Border Grid.Row="0"
Style="{StaticResource PanelBorderStyle}"
Margin="0,0,0,8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Models:" FontWeight="SemiBold" Margin="0,0,8,0" />
<TextBlock Text="{Binding ModelStatus}" Margin="0,0,24,0" />
<TextBlock Text="Script:" FontWeight="SemiBold" Margin="0,0,8,0" />
<Border Padding="6,2"
CornerRadius="4"
Background="{Binding ScriptComposition, Converter={StaticResource ScriptCompositionToBrushConverter}}">
<TextBlock Text="{Binding ScriptComposition}"
Foreground="White"
FontWeight="SemiBold" />
</Border>
<TextBlock Text="Last run:" FontWeight="SemiBold" Margin="24,0,8,0" />
<TextBlock>
<Run Text="{Binding ElapsedMilliseconds, Mode=OneWay}" />
<Run Text=" ms" />
</TextBlock>
<TextBlock Text="Entities:" FontWeight="SemiBold" Margin="24,0,8,0" />
<TextBlock Text="{Binding EntityCount}" />
</StackPanel>
<TextBlock Grid.Column="1"
Text="{Binding StatusMessage}"
VerticalAlignment="Center"
Foreground="#4B5563" />
</Grid>
</Border>
<!-- Main resizable workspace -->
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="260" MinWidth="180" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" MinWidth="500" />
</Grid.ColumnDefinitions>
<!-- Test prompts -->
<Border Grid.Column="0" Style="{StaticResource PanelBorderStyle}">
<DockPanel>
<TextBlock DockPanel.Dock="Top"
Text="Test Prompts"
FontSize="16"
FontWeight="SemiBold"
Margin="0,0,0,8" />
<TextBox DockPanel.Dock="Top"
Margin="0,0,0,8"
Text="{Binding PromptFilter, UpdateSourceTrigger=PropertyChanged}"
ToolTip="Filter by name, category, language, or description" />
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,8,0,0">
<Button Content="Run All"
Command="{Binding RunAllScenariosCommand}" />
</StackPanel>
<ListBox ItemsSource="{Binding PromptsView}"
SelectedItem="{Binding SelectedPrompt}"
DisplayMemberPath="DisplayLabel">
<ListBox.GroupStyle>
<GroupStyle>
<GroupStyle.HeaderTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}"
FontWeight="Bold"
Margin="0,8,0,4" />
</DataTemplate>
</GroupStyle.HeaderTemplate>
</GroupStyle>
</ListBox.GroupStyle>
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="ToolTip" Value="{Binding Description}" />
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
</DockPanel>
</Border>
<GridSplitter Grid.Column="1"
Style="{StaticResource GridSplitterStyle}"
Width="6"
HorizontalAlignment="Center"
VerticalAlignment="Stretch" />
<!-- Input + detection + sanitized (nested splitters) -->
<Grid Grid.Column="2" Margin="8,0,0,0">
<Grid.RowDefinitions>
<RowDefinition Height="*" MinHeight="220" />
<RowDefinition Height="Auto" />
<RowDefinition Height="200" MinHeight="120" />
</Grid.RowDefinitions>
<!-- Input prompt | Detection details -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" MinWidth="280" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="1.15*" MinWidth="420" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Style="{StaticResource PanelBorderStyle}">
<DockPanel>
<TextBlock DockPanel.Dock="Top"
Text="Input Prompt"
FontSize="16"
FontWeight="SemiBold"
Margin="0,0,0,8" />
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,8,0,0">
<Button Content="Redact"
Command="{Binding RedactCommand}" />
<Button Content="Clear"
Style="{StaticResource SecondaryButtonStyle}"
Command="{Binding ClearCommand}" />
</StackPanel>
<TextBox Text="{Binding InputPrompt, UpdateSourceTrigger=PropertyChanged}"
AcceptsReturn="True"
TextWrapping="Wrap"
VerticalScrollBarVisibility="Auto"
FontSize="14" />
</DockPanel>
</Border>
<GridSplitter Grid.Column="1"
Style="{StaticResource GridSplitterStyle}"
Width="6"
HorizontalAlignment="Center"
VerticalAlignment="Stretch" />
<!-- Detection details: entities + placeholders stacked with splitter -->
<Border Grid.Column="2" Style="{StaticResource PanelBorderStyle}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" MinHeight="100" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" MinHeight="80" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<DockPanel Grid.Row="0">
<TextBlock DockPanel.Dock="Top"
Text="Detected Entities"
FontSize="15"
FontWeight="SemiBold"
Margin="0,0,0,6" />
<DataGrid ItemsSource="{Binding DetectedEntities}">
<DataGrid.Columns>
<DataGridTextColumn Header="Type" Binding="{Binding Type}" Width="100" />
<DataGridTextColumn Header="Value" Binding="{Binding Value}" Width="2*" MinWidth="120" />
<DataGridTextColumn Header="Source" Binding="{Binding Source}" Width="90" />
<DataGridTextColumn Header="Start" Binding="{Binding StartIndex}" Width="60" />
<DataGridTextColumn Header="Length" Binding="{Binding Length}" Width="65" />
<DataGridTextColumn Header="Confidence" Binding="{Binding Confidence}" Width="80" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
<GridSplitter Grid.Row="1"
Style="{StaticResource GridSplitterStyle}"
Height="6"
HorizontalAlignment="Stretch"
VerticalAlignment="Center" />
<DockPanel Grid.Row="2">
<TextBlock DockPanel.Dock="Top"
Text="Placeholder Map"
FontSize="15"
FontWeight="SemiBold"
Margin="0,6,0,6" />
<DataGrid ItemsSource="{Binding PlaceholderMap}">
<DataGrid.Columns>
<DataGridTextColumn Header="Placeholder" Binding="{Binding Placeholder}" Width="140" />
<DataGridTextColumn Header="Original Value" Binding="{Binding OriginalValue}" Width="*" MinWidth="160" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
<Border Grid.Row="3"
Margin="0,8,0,0"
Padding="8"
Background="#FEF2F2"
BorderBrush="{StaticResource WarningBrush}"
BorderThickness="1"
Visibility="{Binding LeakWarning, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock Text="Leak warning: a detected PII value still appears in the sanitized output."
Foreground="{StaticResource WarningBrush}"
TextWrapping="Wrap" />
</Border>
</Grid>
</Border>
</Grid>
<GridSplitter Grid.Row="1"
Style="{StaticResource GridSplitterStyle}"
Height="6"
HorizontalAlignment="Stretch"
VerticalAlignment="Center" />
<!-- Sanitized output -->
<Border Grid.Row="2" Style="{StaticResource PanelBorderStyle}">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="Sanitized Output"
FontSize="16"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button Content="Copy"
Margin="16,0,0,0"
Style="{StaticResource SecondaryButtonStyle}"
Command="{Binding CopySanitizedCommand}" />
<Button Content="Send Mock LLM"
Command="{Binding SendToMockLlmCommand}" />
</StackPanel>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBox Grid.Row="0"
Text="{Binding SanitizedOutput, Mode=OneWay}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
VerticalScrollBarVisibility="Auto"
FontSize="14" />
<TextBox Grid.Row="1"
Margin="0,8,0,0"
Text="{Binding MockLlmResponse, Mode=OneWay}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
VerticalScrollBarVisibility="Auto"
MinHeight="60"
FontSize="13"
Visibility="{Binding MockLlmResponse, Converter={StaticResource StringNotEmptyToVisibilityConverter}}" />
</Grid>
</DockPanel>
</Border>
</Grid>
</Grid>
<!-- Batch results -->
<Expander Grid.Row="2"
Header="Batch Results"
IsExpanded="{Binding IsBatchExpanded}"
Margin="0,8,0,0"
Background="{StaticResource PanelBrush}"
BorderBrush="{StaticResource BorderBrushColor}"
BorderThickness="1"
Padding="8">
<DockPanel MinHeight="120">
<TextBlock DockPanel.Dock="Top"
Text="{Binding BatchSummary}"
FontWeight="SemiBold"
Margin="0,0,0,8" />
<DataGrid ItemsSource="{Binding BatchResults}">
<DataGrid.Columns>
<DataGridTextColumn Header="Scenario" Binding="{Binding Scenario.Name}" Width="180" />
<DataGridTextColumn Header="Language" Binding="{Binding Scenario.Language}" Width="80" />
<DataGridTextColumn Header="Category" Binding="{Binding Scenario.Category}" Width="140" />
<DataGridTextColumn Header="Entities" Binding="{Binding EntityCount}" Width="70" />
<DataGridTextColumn Header="ms" Binding="{Binding ElapsedMilliseconds}" Width="60" />
<DataGridTemplateColumn Header="Result" Width="70">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock FontWeight="SemiBold"
Foreground="{Binding Passed, Converter={StaticResource PassFailBrushConverter}}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding Passed}" Value="True">
<Setter Property="Text" Value="PASS" />
</DataTrigger>
<DataTrigger Binding="{Binding Passed}" Value="False">
<Setter Property="Text" Value="FAIL" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Reason" Binding="{Binding FailureReason}" Width="*" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Expander>
</Grid>
</Window>

View File

@@ -0,0 +1,13 @@
using System.Windows;
using PiiRedaction.TestHarness.Wpf.ViewModels;
namespace PiiRedaction.TestHarness.Wpf;
public partial class MainWindow : Window
{
public MainWindow(MainViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}

View File

@@ -0,0 +1,23 @@
namespace PiiRedaction.TestHarness.Wpf.Models;
public sealed record RedactionOutcome(
string OriginalPrompt,
string SanitizedPrompt,
IReadOnlyList<RedactionDisplayModel> DetectedEntities,
IReadOnlyList<PlaceholderDisplayModel> Placeholders,
long ElapsedMilliseconds,
bool HasLeak);
public sealed record BatchScenarioResult(
TestPromptScenario Scenario,
bool Passed,
string? FailureReason,
int EntityCount,
long ElapsedMilliseconds);
public sealed record BatchRunSummary(
int Total,
int Passed,
int Failed,
IReadOnlyList<BatchScenarioResult> Results,
long TotalElapsedMilliseconds);

View File

@@ -0,0 +1,21 @@
namespace PiiRedaction.TestHarness.Wpf.Models;
public enum ModelAvailability
{
Ready,
Missing,
Disabled
}
public sealed record NerModelStatus(
string ModelName,
ModelAvailability Availability,
string Path);
public sealed record ModelStatusSnapshot(
NerModelStatus English,
NerModelStatus Tamil)
{
public string Summary =>
$"English NER: {English.Availability} | Tamil NER: {Tamil.Availability}";
}

View File

@@ -0,0 +1,29 @@
using PiiRedaction.Core.Models;
namespace PiiRedaction.TestHarness.Wpf.Models;
public sealed class RedactionDisplayModel
{
public required string Type { get; init; }
public required string Value { get; init; }
public required string Source { get; init; }
public int StartIndex { get; init; }
public int Length { get; init; }
public string? Confidence { get; init; }
public static RedactionDisplayModel FromEntity(PiiEntity entity) => new()
{
Type = entity.Type.ToString(),
Value = entity.Value,
Source = entity.Source.ToString(),
StartIndex = entity.StartIndex,
Length = entity.Length,
Confidence = entity.Confidence?.ToString("F2")
};
}
public sealed class PlaceholderDisplayModel
{
public required string Placeholder { get; init; }
public required string OriginalValue { get; init; }
}

View File

@@ -0,0 +1,18 @@
namespace PiiRedaction.TestHarness.Wpf.Models;
public enum PromptLanguage
{
English,
Tamil,
Mixed,
Tanglish
}
public sealed record TestPromptScenario(
string Id,
string Name,
PromptLanguage Language,
string Category,
string Description,
string Prompt,
bool ExpectDetections);

View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<ApplicationIcon />
<RootNamespace>PiiRedaction.TestHarness.Wpf</RootNamespace>
<AssemblyName>PiiRedaction.TestHarness.Wpf</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\PiiRedaction.Core\PiiRedaction.Core.csproj" />
<ProjectReference Include="..\PiiRedaction.Infrastructure\PiiRedaction.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,68 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<SolidColorBrush x:Key="AppBackgroundBrush" Color="#F3F4F6" />
<SolidColorBrush x:Key="PanelBrush" Color="White" />
<SolidColorBrush x:Key="BorderBrushColor" Color="#D1D5DB" />
<SolidColorBrush x:Key="AccentBrush" Color="#2563EB" />
<SolidColorBrush x:Key="WarningBrush" Color="#DC2626" />
<Style TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe UI" />
<Setter Property="Foreground" Value="#111827" />
</Style>
<Style TargetType="TextBox">
<Setter Property="FontFamily" Value="Segoe UI" />
<Setter Property="Padding" Value="8" />
<Setter Property="BorderBrush" Value="{StaticResource BorderBrushColor}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style TargetType="Button">
<Setter Property="FontFamily" Value="Segoe UI" />
<Setter Property="Padding" Value="12,6" />
<Setter Property="Margin" Value="0,0,8,0" />
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
<Setter Property="Foreground" Value="White" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style x:Key="SecondaryButtonStyle" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Background" Value="#E5E7EB" />
<Setter Property="Foreground" Value="#111827" />
</Style>
<Style TargetType="GroupBox">
<Setter Property="Margin" Value="0,0,0,8" />
<Setter Property="Padding" Value="8" />
<Setter Property="BorderBrush" Value="{StaticResource BorderBrushColor}" />
</Style>
<Style TargetType="DataGrid">
<Setter Property="FontFamily" Value="Segoe UI" />
<Setter Property="AutoGenerateColumns" Value="False" />
<Setter Property="IsReadOnly" Value="True" />
<Setter Property="HeadersVisibility" Value="Column" />
<Setter Property="GridLinesVisibility" Value="Horizontal" />
<Setter Property="BorderBrush" Value="{StaticResource BorderBrushColor}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CanUserResizeColumns" Value="True" />
<Setter Property="CanUserReorderColumns" Value="True" />
<Setter Property="HorizontalScrollBarVisibility" Value="Auto" />
<Setter Property="RowHeaderWidth" Value="0" />
</Style>
<Style x:Key="GridSplitterStyle" TargetType="GridSplitter">
<Setter Property="Background" Value="#E5E7EB" />
<Setter Property="ShowsPreview" Value="True" />
<Setter Property="ResizeBehavior" Value="PreviousAndNext" />
</Style>
<Style x:Key="PanelBorderStyle" TargetType="Border">
<Setter Property="Background" Value="{StaticResource PanelBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderBrushColor}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="8" />
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,8 @@
using PiiRedaction.TestHarness.Wpf.Models;
namespace PiiRedaction.TestHarness.Wpf.Services;
public interface IModelStatusService
{
ModelStatusSnapshot GetStatus();
}

View File

@@ -0,0 +1,17 @@
using PiiRedaction.TestHarness.Wpf.Models;
namespace PiiRedaction.TestHarness.Wpf.Services;
public interface IRedactionAppService
{
Task<RedactionOutcome> RedactAsync(string prompt, CancellationToken cancellationToken = default);
Task<string> SendToMockLlmAsync(string sanitizedPrompt, CancellationToken cancellationToken = default);
BatchScenarioResult EvaluateScenario(TestPromptScenario scenario, RedactionOutcome outcome);
Task<BatchRunSummary> RunAllScenariosAsync(
IReadOnlyList<TestPromptScenario> scenarios,
IProgress<(int Current, int Total, string Name)>? progress = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,8 @@
using PiiRedaction.Core.Detection;
namespace PiiRedaction.TestHarness.Wpf.Services;
public interface IScriptAnalysisService
{
ScriptComposition GetComposition(string text);
}

View File

@@ -0,0 +1,8 @@
using PiiRedaction.TestHarness.Wpf.Models;
namespace PiiRedaction.TestHarness.Wpf.Services;
public interface ITestPromptCatalog
{
IReadOnlyList<TestPromptScenario> All { get; }
}

View File

@@ -0,0 +1,46 @@
using Microsoft.Extensions.Options;
using PiiRedaction.Core.Configuration;
using PiiRedaction.Infrastructure.Onnx;
using PiiRedaction.TestHarness.Wpf.Models;
namespace PiiRedaction.TestHarness.Wpf.Services;
public sealed class ModelStatusService : IModelStatusService
{
private readonly EnglishOnnxNerRunner _englishRunner;
private readonly TamilOnnxNerRunner _tamilRunner;
private readonly PiiRedactionOptions _options;
public ModelStatusService(
EnglishOnnxNerRunner englishRunner,
TamilOnnxNerRunner tamilRunner,
IOptions<PiiRedactionOptions> options)
{
_englishRunner = englishRunner;
_tamilRunner = tamilRunner;
_options = options.Value;
}
public ModelStatusSnapshot GetStatus()
{
var englishPath = OnnxAssetPathResolver.ResolveModelPath(
_options.EnglishOnnxModelPath,
_options.OnnxModelPath);
var tamilPath = OnnxAssetPathResolver.ResolveModelPath(_options.TamilOnnxModelPath);
var englishAvailability = _englishRunner.IsModelAvailable
? ModelAvailability.Ready
: ModelAvailability.Missing;
var tamilAvailability = !_options.EnableTamilNer
? ModelAvailability.Disabled
: _tamilRunner.IsModelAvailable
? ModelAvailability.Ready
: ModelAvailability.Missing;
return new ModelStatusSnapshot(
new NerModelStatus("English", englishAvailability, englishPath),
new NerModelStatus("Tamil", tamilAvailability, tamilPath));
}
}

View File

@@ -0,0 +1,116 @@
using System.Diagnostics;
using PiiRedaction.Core.Abstractions;
using PiiRedaction.Core.Models;
using PiiRedaction.TestHarness.Wpf.Models;
namespace PiiRedaction.TestHarness.Wpf.Services;
public sealed class RedactionAppService : IRedactionAppService
{
private readonly IPromptSanitizer _sanitizer;
private readonly ILlmPromptService _llmPromptService;
public RedactionAppService(IPromptSanitizer sanitizer, ILlmPromptService llmPromptService)
{
_sanitizer = sanitizer;
_llmPromptService = llmPromptService;
}
public Task<RedactionOutcome> RedactAsync(string prompt, CancellationToken cancellationToken = default) =>
Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
var stopwatch = Stopwatch.StartNew();
var result = _sanitizer.Sanitize(new SanitizationRequest(prompt));
stopwatch.Stop();
return MapOutcome(result, stopwatch.ElapsedMilliseconds);
}, cancellationToken);
public Task<string> SendToMockLlmAsync(string sanitizedPrompt, CancellationToken cancellationToken = default) =>
_llmPromptService.SendPromptAsync(sanitizedPrompt, cancellationToken);
public BatchScenarioResult EvaluateScenario(TestPromptScenario scenario, RedactionOutcome outcome)
{
var entityCount = outcome.DetectedEntities.Count;
string? failureReason = null;
if (scenario.ExpectDetections && entityCount == 0)
{
failureReason = "Expected at least one PII detection but found none.";
}
else if (!scenario.ExpectDetections && entityCount > 0)
{
failureReason = $"Expected no detections but found {entityCount}.";
}
else if (outcome.HasLeak)
{
failureReason = "Detected PII value still present in sanitized output.";
}
return new BatchScenarioResult(
scenario,
failureReason is null,
failureReason,
entityCount,
outcome.ElapsedMilliseconds);
}
public async Task<BatchRunSummary> RunAllScenariosAsync(
IReadOnlyList<TestPromptScenario> scenarios,
IProgress<(int Current, int Total, string Name)>? progress = null,
CancellationToken cancellationToken = default)
{
var results = new List<BatchScenarioResult>(scenarios.Count);
var totalStopwatch = Stopwatch.StartNew();
for (var index = 0; index < scenarios.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var scenario = scenarios[index];
progress?.Report((index + 1, scenarios.Count, scenario.Name));
var outcome = await RedactAsync(scenario.Prompt, cancellationToken).ConfigureAwait(false);
results.Add(EvaluateScenario(scenario, outcome));
}
totalStopwatch.Stop();
var passed = results.Count(result => result.Passed);
return new BatchRunSummary(
scenarios.Count,
passed,
scenarios.Count - passed,
results,
totalStopwatch.ElapsedMilliseconds);
}
private static RedactionOutcome MapOutcome(SanitizationResult result, long elapsedMilliseconds)
{
var entities = result.DetectedEntities
.Select(RedactionDisplayModel.FromEntity)
.ToList();
var placeholders = result.Redaction.PlaceholderMap
.Select(pair => new PlaceholderDisplayModel
{
Placeholder = pair.Key,
OriginalValue = pair.Value
})
.ToList();
var hasLeak = result.DetectedEntities.Any(entity =>
!string.IsNullOrWhiteSpace(entity.Value) &&
result.SanitizedPrompt.Contains(entity.Value, StringComparison.Ordinal));
return new RedactionOutcome(
result.OriginalPrompt,
result.SanitizedPrompt,
entities,
placeholders,
elapsedMilliseconds,
hasLeak);
}
}

View File

@@ -0,0 +1,13 @@
using PiiRedaction.Core.Detection;
namespace PiiRedaction.TestHarness.Wpf.Services;
public sealed class ScriptAnalysisService : IScriptAnalysisService
{
private readonly ScriptRouter _scriptRouter = new();
public ScriptComposition GetComposition(string text) =>
string.IsNullOrWhiteSpace(text)
? ScriptComposition.NoLetters
: _scriptRouter.GetComposition(text);
}

View File

@@ -0,0 +1,194 @@
using PiiRedaction.TestHarness.Wpf.Models;
namespace PiiRedaction.TestHarness.Wpf.Services;
public sealed class TestPromptCatalog : ITestPromptCatalog
{
public IReadOnlyList<TestPromptScenario> All { get; } =
[
Scenario(
"FullFinancialWithCustomer",
PromptLanguage.English,
"NER + Regex + Domain",
"Canonical demo: person name plus email, phone, loan number, and PAN.",
"Customer Ravi Kumar with email ravi.kumar@gmail.com and phone 9876543210 has LoanNumber LN-456789 and PAN ABCDE1234F. Please summarize this customer issue.",
expectDetections: true),
Scenario(
"CustomerNameOnly",
PromptLanguage.English,
"NER",
"Person name detected via ONNX NER after 'Customer' keyword.",
"Customer Anita Sharma reported unauthorized transactions on her savings account.",
expectDetections: true),
Scenario(
"MrTitlePerson",
PromptLanguage.English,
"NER",
"Person detected via ONNX NER (title prefix Mr.).",
"Mr. John Smith called about a duplicate debit on 15 March.",
expectDetections: true),
Scenario(
"MrsTitlePerson",
PromptLanguage.English,
"NER",
"Person detected via ONNX NER (title prefix Mrs.).",
"Mrs. Lakshmi Reddy requested a callback regarding LN-112233.",
expectDetections: true),
Scenario(
"DrTitlePerson",
PromptLanguage.English,
"NER",
"Person detected via ONNX NER (title prefix Dr).",
"Dr. Jane Doe escalated a complaint about delayed loan disbursement.",
expectDetections: true),
Scenario(
"TwoCustomersInOnePrompt",
PromptLanguage.English,
"NER",
"Two distinct person names in the same prompt.",
"Customer Ravi Kumar and Customer Priya Nair disputed the same charge.",
expectDetections: true),
Scenario(
"PersonWithDomainIds",
PromptLanguage.English,
"NER + Domain",
"Person name combined with business identifiers.",
"Customer Meera Iyer holds CID-7070 and account ACC-606060 for verification.",
expectDetections: true),
Scenario(
"PersonWithEmailNoPhone",
PromptLanguage.English,
"NER + Regex",
"Person and email without phone number.",
"Customer Arjun Mehta wrote from arjun.mehta@company.in about KYC renewal.",
expectDetections: true),
Scenario(
"AllRegexTypes",
PromptLanguage.English,
"Regex",
"Email, phone, PAN, Aadhaar, and credit card in one prompt.",
"Email a@b.co phone 9001234567 PAN ABCDE1234F aadhaar 1234 5678 9012 card 4111-1111-1111-1111.",
expectDetections: true),
Scenario(
"AllDomainIds",
PromptLanguage.English,
"Domain",
"Loan number, customer ID, and account number together.",
"Please verify LN-100200 for CustomerId CID-3000 on AccountNumber ACC-400500.",
expectDetections: true),
Scenario(
"TamilCustomerNameOnly",
PromptLanguage.Tamil,
"NER (Tamil)",
"Tamil script person name detected via Tamil ONNX NER.",
"வாடிக்கையாளர் ராஜேஷ் குமார் சேமிப்பு கணக்கில் அங்கீகரிக்கப்படாத பரிவர்த்தனைகளைப் புகாரளித்தார்.",
expectDetections: true),
Scenario(
"TamilWithPhonePan",
PromptLanguage.Tamil,
"NER (Tamil) + Regex",
"Tamil script person plus phone and PAN (regex).",
"வாடிக்கையாளர் ராஜேஷ் குமார் தொலைபேசி 9876543210 PAN ABCDE1234F.",
expectDetections: true),
Scenario(
"TanglishCustomer",
PromptLanguage.Tanglish,
"NER (English/Tanglish)",
"Latin-script Tanglish person name via English ONNX NER.",
"Customer Senthil phone 9876543210 reported a failed UPI transfer.",
expectDetections: true),
Scenario(
"MixedTamilEnglish",
PromptLanguage.Mixed,
"NER (Mixed)",
"Code-mixed Tamil and English — both script routers may contribute person spans.",
"வாடிக்கையாளர் Ravi Kumar phone 9876543210 disputed the charge.",
expectDetections: true),
Scenario(
"TamilFullFinancial",
PromptLanguage.Tamil,
"NER (Tamil) + Regex + Domain",
"Tamil person with email, phone, loan number, and PAN (canonical demo in Tamil).",
"வாடிக்கையாளர் ராஜேஷ் குமார் மின்னஞ்சல் ravi.kumar@gmail.com தொலைபேசி 9876543210 LoanNumber LN-456789 PAN ABCDE1234F. இந்த வாடிக்கையாளர் புகாரை சுருக்கமாக கூறுங்கள்.",
expectDetections: true),
Scenario(
"NoPiiCleanTicket",
PromptLanguage.English,
"Negative",
"No PII — prompt passes through unchanged.",
"What is the status of ticket TKT-99887 and when will the API maintenance end?",
expectDetections: false),
Scenario(
"NegativeWorkflowQuestion",
PromptLanguage.English,
"Negative",
"General workflow question with no regulated identifiers.",
"Summarize the retail loan approval workflow and typical SLA milestones.",
expectDetections: false),
Scenario(
"EdgePhoneOnly",
PromptLanguage.English,
"Edge + Regex",
"Digits-only phone without a person name.",
"Callback requested on 9123456780 regarding branch hours.",
expectDetections: true),
Scenario(
"EdgeLongMixed",
PromptLanguage.Mixed,
"Edge + NER (Mixed)",
"Longer mixed-language prompt with person and phone.",
"வாடிக்கையாளர் Priya Nair called from Chennai about a delayed NEFT transfer. She asked whether LoanNumber LN-909090 is linked to account ACC-808080 and wants an email confirmation sent to priya.nair@example.com on phone 9988776655.",
expectDetections: true),
Scenario(
"LeakCheckNestedEmail",
PromptLanguage.English,
"LeakCheck + Regex",
"Email embedded in a sentence — placeholders must fully replace the address.",
"Please forward the statement for customer.support@banking.example to the operations desk.",
expectDetections: true),
Scenario(
"TamilEdgePunctuation",
PromptLanguage.Tamil,
"TamilEdge + NER (Tamil)",
"Tamil name surrounded by punctuation and Tamil numerals.",
"வாடிக்கையாளர் (ராஜேஷ் குமார்) — தொலைபேசி ௯௮௭௬௫௪௩௨௧௦ — உதவி தேவை.",
expectDetections: true),
Scenario(
"TanglishLatinInTamilSentence",
PromptLanguage.Tanglish,
"Tanglish + NER",
"Latin person name inside otherwise Tamil context.",
"வாடிக்கையாளர் Arun Kumar அவர்களின் KYC ஆவணம் நிலுவையில் உள்ளது.",
expectDetections: true)
];
private static TestPromptScenario Scenario(
string name,
PromptLanguage language,
string category,
string description,
string prompt,
bool expectDetections) =>
new(name, name, language, category, description, prompt, expectDetections);
}

View File

@@ -0,0 +1,337 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows;
using System.Windows.Data;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PiiRedaction.Core.Detection;
using PiiRedaction.TestHarness.Wpf.Models;
using PiiRedaction.TestHarness.Wpf.Services;
namespace PiiRedaction.TestHarness.Wpf.ViewModels;
public partial class MainViewModel : ObservableObject
{
private readonly IRedactionAppService _redactionAppService;
private readonly ITestPromptCatalog _promptCatalog;
private readonly IScriptAnalysisService _scriptAnalysisService;
private readonly IModelStatusService _modelStatusService;
public MainViewModel(
IRedactionAppService redactionAppService,
ITestPromptCatalog promptCatalog,
IScriptAnalysisService scriptAnalysisService,
IModelStatusService modelStatusService)
{
_redactionAppService = redactionAppService;
_promptCatalog = promptCatalog;
_scriptAnalysisService = scriptAnalysisService;
_modelStatusService = modelStatusService;
PromptItems = new ObservableCollection<TestPromptItemViewModel>(
_promptCatalog.All.Select(scenario => new TestPromptItemViewModel(scenario)));
PromptsView = CollectionViewSource.GetDefaultView(PromptItems);
PromptsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(TestPromptItemViewModel.Language)));
PromptsView.SortDescriptions.Add(new SortDescription(nameof(TestPromptItemViewModel.Name), ListSortDirection.Ascending));
PromptsView.Filter = FilterPrompt;
DetectedEntities = [];
PlaceholderMap = [];
BatchResults = [];
RefreshModelStatus();
UpdateScriptComposition();
}
public ICollectionView PromptsView { get; }
public ObservableCollection<TestPromptItemViewModel> PromptItems { get; }
public ObservableCollection<RedactionDisplayModel> DetectedEntities { get; }
public ObservableCollection<PlaceholderDisplayModel> PlaceholderMap { get; }
public ObservableCollection<BatchScenarioResult> BatchResults { get; }
[ObservableProperty]
private string _inputPrompt = string.Empty;
[ObservableProperty]
private string _sanitizedOutput = string.Empty;
[ObservableProperty]
private string _originalPrompt = string.Empty;
[ObservableProperty]
private string _mockLlmResponse = string.Empty;
[ObservableProperty]
private TestPromptItemViewModel? _selectedPrompt;
[ObservableProperty]
private ScriptComposition _scriptComposition = ScriptComposition.NoLetters;
[ObservableProperty]
private long _elapsedMilliseconds;
[ObservableProperty]
private int _entityCount;
[ObservableProperty]
private string _statusMessage = "Ready";
[ObservableProperty]
private bool _isBusy;
[ObservableProperty]
private string _modelStatus = string.Empty;
[ObservableProperty]
private bool _leakWarning;
[ObservableProperty]
private string _promptFilter = string.Empty;
[ObservableProperty]
private string _batchSummary = string.Empty;
[ObservableProperty]
private bool _isBatchExpanded;
partial void OnInputPromptChanged(string value)
{
UpdateScriptComposition();
RedactCommand.NotifyCanExecuteChanged();
}
partial void OnSelectedPromptChanged(TestPromptItemViewModel? value)
{
if (value is null)
{
return;
}
ClearRedactionResults();
InputPrompt = value.Prompt;
StatusMessage = $"Loaded prompt: {value.Name}";
}
[RelayCommand]
private void Clear()
{
InputPrompt = string.Empty;
SelectedPrompt = null;
ClearRedactionResults();
BatchResults.Clear();
BatchSummary = string.Empty;
IsBatchExpanded = false;
StatusMessage = "Cleared.";
UpdateScriptComposition();
}
private void ClearRedactionResults()
{
SanitizedOutput = string.Empty;
OriginalPrompt = string.Empty;
MockLlmResponse = string.Empty;
DetectedEntities.Clear();
PlaceholderMap.Clear();
LeakWarning = false;
EntityCount = 0;
ElapsedMilliseconds = 0;
SendToMockLlmCommand.NotifyCanExecuteChanged();
}
[RelayCommand(CanExecute = nameof(CanRedact))]
private async Task RedactAsync(CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(InputPrompt))
{
return;
}
try
{
IsBusy = true;
StatusMessage = "Redacting...";
var outcome = await _redactionAppService.RedactAsync(InputPrompt, cancellationToken)
.ConfigureAwait(true);
ApplyOutcome(outcome);
StatusMessage = $"Redaction complete in {outcome.ElapsedMilliseconds} ms.";
}
catch (OperationCanceledException)
{
StatusMessage = "Redaction cancelled.";
}
catch (Exception ex)
{
StatusMessage = $"Redaction failed: {ex.Message}";
}
finally
{
IsBusy = false;
RedactCommand.NotifyCanExecuteChanged();
RunAllScenariosCommand.NotifyCanExecuteChanged();
SendToMockLlmCommand.NotifyCanExecuteChanged();
}
}
private bool CanRedact() => !IsBusy && !string.IsNullOrWhiteSpace(InputPrompt);
[RelayCommand]
private void CopySanitized()
{
if (string.IsNullOrWhiteSpace(SanitizedOutput))
{
StatusMessage = "Nothing to copy.";
return;
}
Clipboard.SetText(SanitizedOutput);
StatusMessage = "Sanitized output copied to clipboard.";
}
[RelayCommand(CanExecute = nameof(CanSendToMockLlm))]
private async Task SendToMockLlmAsync(CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(SanitizedOutput))
{
return;
}
try
{
IsBusy = true;
StatusMessage = "Sending to mock LLM...";
MockLlmResponse = await _redactionAppService
.SendToMockLlmAsync(SanitizedOutput, cancellationToken)
.ConfigureAwait(true);
StatusMessage = "Mock LLM response received.";
}
catch (OperationCanceledException)
{
StatusMessage = "Mock LLM call cancelled.";
}
catch (Exception ex)
{
StatusMessage = $"Mock LLM call failed: {ex.Message}";
}
finally
{
IsBusy = false;
RedactCommand.NotifyCanExecuteChanged();
RunAllScenariosCommand.NotifyCanExecuteChanged();
SendToMockLlmCommand.NotifyCanExecuteChanged();
}
}
private bool CanSendToMockLlm() => !IsBusy && !string.IsNullOrWhiteSpace(SanitizedOutput);
[RelayCommand(CanExecute = nameof(CanRunBatch))]
private async Task RunAllScenariosAsync(CancellationToken cancellationToken)
{
try
{
IsBusy = true;
IsBatchExpanded = true;
BatchResults.Clear();
BatchSummary = "Running batch validation...";
StatusMessage = "Running all scenarios...";
var progress = new Progress<(int Current, int Total, string Name)>(report =>
{
StatusMessage = $"Batch {report.Current}/{report.Total}: {report.Name}";
});
var summary = await _redactionAppService
.RunAllScenariosAsync(_promptCatalog.All, progress, cancellationToken)
.ConfigureAwait(true);
BatchResults.Clear();
foreach (var result in summary.Results)
{
BatchResults.Add(result);
}
BatchSummary =
$"{summary.Passed}/{summary.Total} passed in {summary.TotalElapsedMilliseconds} ms";
StatusMessage = summary.Failed == 0
? $"Batch complete: all {summary.Total} scenarios passed."
: $"Batch complete: {summary.Failed} scenario(s) failed.";
}
catch (OperationCanceledException)
{
StatusMessage = "Batch run cancelled.";
}
catch (Exception ex)
{
StatusMessage = $"Batch run failed: {ex.Message}";
}
finally
{
IsBusy = false;
RedactCommand.NotifyCanExecuteChanged();
RunAllScenariosCommand.NotifyCanExecuteChanged();
SendToMockLlmCommand.NotifyCanExecuteChanged();
}
}
private bool CanRunBatch() => !IsBusy;
partial void OnPromptFilterChanged(string value) => PromptsView.Refresh();
private void ApplyOutcome(RedactionOutcome outcome)
{
OriginalPrompt = outcome.OriginalPrompt;
SanitizedOutput = outcome.SanitizedPrompt;
ElapsedMilliseconds = outcome.ElapsedMilliseconds;
EntityCount = outcome.DetectedEntities.Count;
LeakWarning = outcome.HasLeak;
DetectedEntities.Clear();
foreach (var entity in outcome.DetectedEntities)
{
DetectedEntities.Add(entity);
}
PlaceholderMap.Clear();
foreach (var placeholder in outcome.Placeholders)
{
PlaceholderMap.Add(placeholder);
}
}
private void RefreshModelStatus()
{
var snapshot = _modelStatusService.GetStatus();
ModelStatus = snapshot.Summary;
}
private void UpdateScriptComposition() =>
ScriptComposition = _scriptAnalysisService.GetComposition(InputPrompt);
private bool FilterPrompt(object item)
{
if (item is not TestPromptItemViewModel promptItem)
{
return false;
}
if (string.IsNullOrWhiteSpace(PromptFilter))
{
return true;
}
var filter = PromptFilter.Trim();
return promptItem.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)
|| promptItem.Category.Contains(filter, StringComparison.OrdinalIgnoreCase)
|| promptItem.Description.Contains(filter, StringComparison.OrdinalIgnoreCase)
|| promptItem.Language.ToString().Contains(filter, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,25 @@
using PiiRedaction.TestHarness.Wpf.Models;
namespace PiiRedaction.TestHarness.Wpf.ViewModels;
public sealed class TestPromptItemViewModel
{
public TestPromptItemViewModel(TestPromptScenario scenario)
{
Scenario = scenario;
}
public TestPromptScenario Scenario { get; }
public string Name => Scenario.Name;
public string Category => Scenario.Category;
public PromptLanguage Language => Scenario.Language;
public string Description => Scenario.Description;
public string Prompt => Scenario.Prompt;
public string DisplayLabel => $"{Name} ({Language})";
}

View File

@@ -0,0 +1,8 @@
{
"PiiRedaction": {
"OnnxModelPath": "models/ner-model.onnx",
"EnglishOnnxModelPath": "models/en/ner-model.onnx",
"TamilOnnxModelPath": "models/ta/model.onnx",
"EnableTamilNer": true
}
}

View File

@@ -0,0 +1,54 @@
using FluentAssertions;
using PiiRedaction.Core.Detection;
using PiiRedaction.Infrastructure.Onnx;
namespace PiiRedaction.Core.Tests.Detection;
[TestFixture]
public sealed class ScriptRouterTests
{
private readonly ScriptRouter _router = new();
[Test]
public void GetComposition_LatinOnly_ReturnsLatinOnly()
{
_router.GetComposition("Customer Ravi Kumar called about billing.")
.Should().Be(ScriptComposition.LatinOnly);
}
[Test]
public void GetComposition_TamilOnly_ReturnsTamilOnly()
{
_router.GetComposition("வாடிக்கையாளர் ராஜேஷ் தொலைபேசி 9876543210")
.Should().Be(ScriptComposition.TamilOnly);
}
[Test]
public void GetComposition_Mixed_ReturnsMixed()
{
_router.GetComposition("Rajesh மற்றும் Priya disputed the charge.")
.Should().Be(ScriptComposition.Mixed);
}
[Test]
public void GetComposition_MixedTamilEnglishSample_ReturnsMixed()
{
_router.GetComposition("வாடிக்கையாளர் Ravi Kumar phone 9876543210 disputed the charge.")
.Should().Be(ScriptComposition.Mixed);
}
[Test]
public void GetComposition_NoLetters_ReturnsNoLetters()
{
_router.GetComposition("9876543210 12345")
.Should().Be(ScriptComposition.NoLetters);
}
[Test]
public void GetComposition_TamilBoundaryChars_AreClassifiedAsTamil()
{
_router.GetComposition("\u0B80").Should().Be(ScriptComposition.TamilOnly);
_router.GetComposition("\u0BFF").Should().Be(ScriptComposition.TamilOnly);
_router.GetComposition("A").Should().Be(ScriptComposition.LatinOnly);
}
}

View File

@@ -8,6 +8,7 @@ namespace PiiRedaction.Core.Tests.Integration;
public sealed class GoldenPromptTests
{
[TestCaseSource(typeof(PromptScenarioCatalog), nameof(PromptScenarioCatalog.AllScenarios))]
[TestCaseSource(typeof(TamilPromptScenarioCatalog), nameof(TamilPromptScenarioCatalog.AllScenarios))]
public void Sanitize_PromptScenario_ProducesExpectedOutput(PromptScenario scenario)
{
var sanitizer = ProductionPipelineFactory.CreateForScenario(scenario);

View File

@@ -0,0 +1,106 @@
using FluentAssertions;
using PiiRedaction.Core.Abstractions;
using PiiRedaction.Core.Models;
using PiiRedaction.Core.Tests.TestSupport;
using PiiRedaction.Tests.Shared;
namespace PiiRedaction.Core.Tests.Integration;
/// <summary>
/// End-to-end pipeline proof using routed English + Tamil ONNX NER models.
/// </summary>
[TestFixture]
[Category("TamilNer")]
public sealed class RealTamilPipelineTests : RealRoutingNerModelFixture
{
private IPromptSanitizer _sanitizer = null!;
[OneTimeSetUp]
public void OneTimeSetUpPipeline()
{
_sanitizer = ProductionPipelineFactory.CreateWithRoutingRealModels(EnglishRunner, TamilRunner);
}
[Test]
public void Sanitize_TamilCustomerNameOnly_RedactsPerson()
{
const string prompt =
"வாடிக்கையாளர் ராஜேஷ் குமார் சேமிப்பு கணக்கில் அங்கீகரிக்கப்படாத பரிவர்த்தனைகளைப் புகாரளித்தார்.";
var result = _sanitizer.Sanitize(new SanitizationRequest(prompt));
result.SanitizedPrompt.Should().Contain("<PERSON_1>");
result.SanitizedPrompt.Should().NotContain("ராஜேஷ்");
result.DetectedEntities.Should().Contain(entity =>
entity.Type == PiiEntityType.Person && entity.Source == PiiDetectionSource.Ner);
}
[Test]
public void Sanitize_TamilWithPhonePan_RedactsPersonPhoneAndPan()
{
const string prompt = "வாடிக்கையாளர் ராஜேஷ் குமார் தொலைபேசி 9876543210 PAN ABCDE1234F.";
var result = _sanitizer.Sanitize(new SanitizationRequest(prompt));
result.SanitizedPrompt.Should().Contain("<PERSON_1>");
result.SanitizedPrompt.Should().Contain("<PHONE_1>");
result.SanitizedPrompt.Should().Contain("<PAN_1>");
result.SanitizedPrompt.Should().NotContainAny("ராஜேஷ்", "9876543210", "ABCDE1234F");
}
[Test]
public void Sanitize_TanglishCustomer_RedactsPersonAndPhone()
{
const string prompt = "Customer Senthil phone 9876543210 reported a failed UPI transfer.";
var result = _sanitizer.Sanitize(new SanitizationRequest(prompt));
result.SanitizedPrompt.Should().Contain("<PERSON_1>");
result.SanitizedPrompt.Should().Contain("<PHONE_1>");
result.SanitizedPrompt.Should().NotContainAny("Senthil", "9876543210");
}
[Test]
public void Sanitize_MixedTamilEnglish_RedactsPersonAndPhone()
{
const string prompt = "வாடிக்கையாளர் Ravi Kumar phone 9876543210 disputed the charge.";
var result = _sanitizer.Sanitize(new SanitizationRequest(prompt));
result.SanitizedPrompt.Should().Contain("<PERSON_1>");
result.SanitizedPrompt.Should().Contain("<PHONE_1>");
result.SanitizedPrompt.Should().NotContainAny("Ravi Kumar", "9876543210");
}
[Test]
public void Sanitize_TamilFullFinancial_RedactsAllPiiTypes()
{
const string prompt =
"வாடிக்கையாளர் ராஜேஷ் குமார் மின்னஞ்சல் ravi.kumar@gmail.com தொலைபேசி 9876543210 LoanNumber LN-456789 PAN ABCDE1234F. இந்த வாடிக்கையாளர் புகாரை சுருக்கமாக கூறுங்கள்.";
var result = _sanitizer.Sanitize(new SanitizationRequest(prompt));
result.SanitizedPrompt.Should().Contain("<PERSON_1>");
result.SanitizedPrompt.Should().Contain("<EMAIL_1>");
result.SanitizedPrompt.Should().Contain("<PHONE_1>");
result.SanitizedPrompt.Should().Contain("<LOAN_NUMBER_1>");
result.SanitizedPrompt.Should().Contain("<PAN_1>");
result.SanitizedPrompt.Should().NotContainAny(
"ராஜேஷ்",
"ravi.kumar@gmail.com",
"9876543210",
"LN-456789",
"ABCDE1234F");
}
[Test]
public void Sanitize_CleanTamilQuestion_PassesThroughWithoutPersonRedaction()
{
const string prompt = "பணத்தை திரும்பப் பெறுவது எப்படி?";
var result = _sanitizer.Sanitize(new SanitizationRequest(prompt));
result.SanitizedPrompt.Should().Be(prompt);
result.DetectedEntities.Should().BeEmpty();
}
}

View File

@@ -25,6 +25,9 @@
<ItemGroup>
<Compile Include="..\TestSupport.Shared\RealNerModelPaths.cs" Link="TestSupport.Shared\RealNerModelPaths.cs" />
<Compile Include="..\TestSupport.Shared\RealNerModelFixture.cs" Link="TestSupport.Shared\RealNerModelFixture.cs" />
<Compile Include="..\TestSupport.Shared\RealTamilNerModelPaths.cs" Link="TestSupport.Shared\RealTamilNerModelPaths.cs" />
<Compile Include="..\TestSupport.Shared\RealTamilNerModelFixture.cs" Link="TestSupport.Shared\RealTamilNerModelFixture.cs" />
<Compile Include="..\TestSupport.Shared\RealRoutingNerModelFixture.cs" Link="TestSupport.Shared\RealRoutingNerModelFixture.cs" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,7 +1,10 @@
using Microsoft.Extensions.Options;
using PiiRedaction.Core.Abstractions;
using PiiRedaction.Core.Configuration;
using PiiRedaction.Core.Detection;
using PiiRedaction.Core.Redaction;
using PiiRedaction.Core.Sanitization;
using PiiRedaction.Infrastructure.Onnx;
namespace PiiRedaction.Core.Tests.TestSupport;
@@ -10,6 +13,15 @@ public static class ProductionPipelineFactory
public static IPromptSanitizer CreateWithRealModel(IOnnxNerModelRunner runner) =>
new PromptSanitizer(CreateCompositeDetector(runner), new PlaceholderPiiRedactor());
public static IPromptSanitizer CreateWithRoutingRealModels(
EnglishOnnxNerRunner englishRunner,
TamilOnnxNerRunner tamilRunner,
IOptions<PiiRedactionOptions>? options = null) =>
CreateWithRealModel(new RoutingOnnxNerModelRunner(
englishRunner,
tamilRunner,
options ?? Options.Create(new PiiRedactionOptions { EnableTamilNer = true })));
public static IPiiDetector CreateCompositeDetector(IOnnxNerModelRunner runner) =>
new CompositePiiDetector(
[

View File

@@ -0,0 +1,63 @@
using NUnit.Framework;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Tests.TestSupport;
/// <summary>
/// Golden end-to-end scenarios for Tamil script, Tanglish, and mixed-script prompts.
/// Uses fake NER spans for person names; regex and domain rules run for real.
/// </summary>
public static class TamilPromptScenarioCatalog
{
public static IEnumerable<TestCaseData> AllScenarios()
{
foreach (var scenario in BuildScenarios())
{
yield return new TestCaseData(scenario).SetName(scenario.Name);
}
}
private static IEnumerable<PromptScenario> BuildScenarios()
{
yield return TamilCustomerNameOnly();
yield return TamilWithPhonePan();
yield return TanglishCustomer();
yield return MixedTamilEnglish();
yield return TamilFullFinancial();
}
private static PromptScenario TamilCustomerNameOnly() => new(
"Tamil_CustomerNameOnly",
"வாடிக்கையாளர் ராஜேஷ் குமார் சேமிப்பு கணக்கில் அங்கீகரிக்கப்படாத பரிவர்த்தனைகளைப் புகாரளித்தார்.",
"வாடிக்கையாளர் <PERSON_1> சேமிப்பு கணக்கில் அங்கீகரிக்கப்படாத பரிவர்த்தனைகளைப் புகாரளித்தார்.",
[PiiEntityType.Person],
["ராஜேஷ் குமார்"]);
private static PromptScenario TamilWithPhonePan() => new(
"Tamil_WithPhonePan",
"வாடிக்கையாளர் ராஜேஷ் குமார் தொலைபேசி 9876543210 PAN ABCDE1234F.",
"வாடிக்கையாளர் <PERSON_1> தொலைபேசி <PHONE_1> PAN <PAN_1>.",
[PiiEntityType.Person, PiiEntityType.Phone, PiiEntityType.Pan],
["ராஜேஷ் குமார்", "9876543210", "ABCDE1234F"]);
private static PromptScenario TanglishCustomer() => new(
"Tanglish_CustomerPhone",
"Customer Senthil phone 9876543210 reported a failed UPI transfer.",
"Customer <PERSON_1> phone <PHONE_1> reported a failed UPI transfer.",
[PiiEntityType.Person, PiiEntityType.Phone],
["Senthil", "9876543210"]);
private static PromptScenario MixedTamilEnglish() => new(
"Mixed_TamilEnglish",
"வாடிக்கையாளர் Ravi Kumar phone 9876543210 disputed the charge.",
"வாடிக்கையாளர் <PERSON_1> phone <PHONE_1> disputed the charge.",
[PiiEntityType.Person, PiiEntityType.Phone],
["Ravi Kumar", "9876543210"]);
private static PromptScenario TamilFullFinancial() => new(
"Tamil_FullFinancial",
"வாடிக்கையாளர் ராஜேஷ் குமார் மின்னஞ்சல் ravi.kumar@gmail.com தொலைபேசி 9876543210 LoanNumber LN-456789 PAN ABCDE1234F. இந்த வாடிக்கையாளர் புகாரை சுருக்கமாக கூறுங்கள்.",
"வாடிக்கையாளர் <PERSON_1> மின்னஞ்சல் <EMAIL_1> தொலைபேசி <PHONE_1> LoanNumber <LOAN_NUMBER_1> PAN <PAN_1>. இந்த வாடிக்கையாளர் புகாரை சுருக்கமாக கூறுங்கள்.",
[PiiEntityType.Person, PiiEntityType.Email, PiiEntityType.Phone, PiiEntityType.LoanNumber, PiiEntityType.Pan],
["ராஜேஷ் குமார்", "ravi.kumar@gmail.com", "9876543210", "LN-456789", "ABCDE1234F"]);
}

View File

@@ -0,0 +1,28 @@
using FluentAssertions;
using PiiRedaction.Core.Detection;
using PiiRedaction.Core.Models;
using PiiRedaction.Infrastructure.Onnx;
namespace PiiRedaction.Infrastructure.Tests.Onnx;
[TestFixture]
public sealed class NerLabelConfigTests
{
[TestCase("B-PER", true)]
[TestCase("I-PER", true)]
[TestCase("B-PERSON", true)]
[TestCase("B-ORG", false)]
public void English_IsPersonLabel_MatchesExpected(string label, bool expected)
{
NerLabelConfig.English.IsPersonLabel(label).Should().Be(expected);
}
[TestCase("B-person-politician", true)]
[TestCase("I-person-artist", true)]
[TestCase("B-location", false)]
[TestCase("O", false)]
public void Tamil_IsPersonLabel_MatchesFineGrainedTags(string label, bool expected)
{
NerLabelConfig.Tamil.IsPersonLabel(label).Should().Be(expected);
}
}

View File

@@ -0,0 +1,70 @@
using FluentAssertions;
using PiiRedaction.Core.Models;
using PiiRedaction.Infrastructure.Onnx;
using PiiRedaction.Tests.Shared;
namespace PiiRedaction.Infrastructure.Tests.Onnx;
[TestFixture]
[Category("TamilNer")]
public sealed class RealTamilNerModelRunnerTests : RealTamilNerModelFixture
{
[Test]
public void IsModelAvailable_LoadsOnnxAndTokenizer()
{
Runner.IsModelAvailable.Should().BeTrue();
}
[TestCase("வாடிக்கையாளர் ராஜேஷ் குமார் அழைத்தார்.", "ராஜேஷ்", "ராஜேஷ் குமார்")]
public void PredictEntities_TamilScript_DetectsPersonEntity(
string prompt,
string expectedNamePart,
string expectedValue)
{
var entities = Runner.PredictEntities(prompt);
entities.Should().Contain(entity =>
entity.Type == PiiEntityType.Person &&
entity.Source == PiiDetectionSource.Ner &&
entity.Value.Contains(expectedNamePart, StringComparison.Ordinal) &&
prompt.AsSpan(entity.StartIndex, entity.Length).ToString() == entity.Value);
entities.Should().Contain(entity => entity.Value == expectedValue);
}
[Test]
public void PredictEntities_TamilWithPhone_DetectsPersonAndLeavesPhoneToRegex()
{
const string prompt = "வாடிக்கையாளர் ராஜேஷ் குமார் தொலைபேசி 9876543210";
var entities = Runner.PredictEntities(prompt);
entities.Should().Contain(entity =>
entity.Type == PiiEntityType.Person &&
entity.Source == PiiDetectionSource.Ner &&
entity.Value.Contains("ராஜேஷ்", StringComparison.Ordinal));
entities.Should().NotContain(entity => entity.Type == PiiEntityType.Phone);
}
[Test]
public void PredictEntities_CleanTamilQuestion_ReturnsNoEntities()
{
const string prompt = "பணத்தை திரும்பப் பெறுவது எப்படி?";
Runner.PredictEntities(prompt).Should().BeEmpty();
}
[TestCase("Customer Senthil phone 9876543210", "Senthil")]
public void PredictEntities_TanglishLatinScript_DoesNotInvokeTamilRunner(
string prompt,
string expectedNamePart)
{
// TamilOnnxNerRunner is script-scoped; Tanglish is handled by English routing in pipeline tests.
// Direct Tamil runner on Latin-only text should not emit person spans.
var entities = Runner.PredictEntities(prompt);
entities.Should().NotContain(entity =>
entity.Type == PiiEntityType.Person &&
entity.Value.Contains(expectedNamePart, StringComparison.OrdinalIgnoreCase));
}
}

View File

@@ -0,0 +1,118 @@
using FluentAssertions;
using PiiRedaction.Core.Detection;
using PiiRedaction.Core.Models;
using PiiRedaction.Infrastructure.Onnx;
namespace PiiRedaction.Infrastructure.Tests.Onnx;
[TestFixture]
public sealed class RoutingOnnxNerModelRunnerTests
{
[Test]
public void PredictEntities_LatinOnly_UsesEnglishRunnerOnly()
{
var english = new FakeLanguageNerRunner("Ravi Kumar");
var tamil = new FakeLanguageNerRunner("தமிழ் பெயர்");
var router = new RoutingOnnxNerModelRunner(english, tamil, enableTamilNer: true);
var entities = router.PredictEntities("Customer Ravi Kumar called.");
entities.Should().ContainSingle(entity => entity.Value == "Ravi Kumar");
english.CallCount.Should().Be(1);
tamil.CallCount.Should().Be(0);
}
[Test]
public void PredictEntities_TamilOnly_UsesTamilRunnerOnly()
{
var english = new FakeLanguageNerRunner("Ravi Kumar");
var tamil = new FakeLanguageNerRunner("ராஜேஷ்");
var router = new RoutingOnnxNerModelRunner(english, tamil, enableTamilNer: true);
var entities = router.PredictEntities("வாடிக்கையாளர் ராஜேஷ்");
entities.Should().ContainSingle(entity => entity.Value == "ராஜேஷ்");
english.CallCount.Should().Be(0);
tamil.CallCount.Should().Be(1);
}
[Test]
public void PredictEntities_Mixed_InvokesBothRunners()
{
var english = new FakeLanguageNerRunner("EnglishName");
var tamil = new FakeLanguageNerRunner("தமிழ்");
var router = new RoutingOnnxNerModelRunner(english, tamil, enableTamilNer: true);
router.PredictEntities("Rajesh மற்றும் Priya");
english.CallCount.Should().Be(1);
tamil.CallCount.Should().Be(1);
}
[Test]
public void PredictEntities_NoLetters_InvokesNeither()
{
var english = new FakeLanguageNerRunner("ignored");
var tamil = new FakeLanguageNerRunner("ignored");
var router = new RoutingOnnxNerModelRunner(english, tamil, enableTamilNer: true);
router.PredictEntities("9876543210").Should().BeEmpty();
english.CallCount.Should().Be(0);
tamil.CallCount.Should().Be(0);
}
[Test]
public void PredictEntities_TamilDisabled_SkipsTamilRunnerForMixedText()
{
var english = new FakeLanguageNerRunner("EnglishName");
var tamil = new FakeLanguageNerRunner("தமிழ்");
var router = new RoutingOnnxNerModelRunner(english, tamil, enableTamilNer: false);
router.PredictEntities("Rajesh மற்றும் Priya");
english.CallCount.Should().Be(1);
tamil.CallCount.Should().Be(0);
}
[Test]
public void MergePersonSpans_PrefersLongerOverlappingSpan()
{
var entities = new[]
{
CreatePerson("Raj", 0, 3),
CreatePerson("Rajesh", 0, 6)
};
var merged = RoutingOnnxNerModelRunner.MergePersonSpans(entities);
merged.Should().ContainSingle(entity => entity.Value == "Rajesh");
}
private static PiiEntity CreatePerson(string value, int start, int length) =>
new(PiiEntityType.Person, value, start, length, PiiDetectionSource.Ner);
private sealed class FakeLanguageNerRunner : IOnnxNerModelRunner
{
private readonly string _personValue;
public FakeLanguageNerRunner(string personValue) => _personValue = personValue;
public int CallCount { get; private set; }
public bool IsModelAvailable => true;
public IReadOnlyList<PiiEntity> PredictEntities(string text)
{
CallCount++;
return
[
new PiiEntity(
PiiEntityType.Person,
_personValue,
0,
_personValue.Length,
PiiDetectionSource.Ner)
];
}
}
}

View File

@@ -0,0 +1,43 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using PiiRedaction.Infrastructure.Onnx;
namespace PiiRedaction.Infrastructure.Tests.Onnx;
[TestFixture]
public sealed class TokenClassifierEncoderFactoryTests
{
[Test]
public void Create_PrefersWordPieceWhenVocabExists()
{
var modelDirectory = ResolveTamilModelDirectory();
if (!File.Exists(Path.Combine(modelDirectory, "vocab.txt")))
{
Assert.Ignore("Tamil vocab.txt not found.");
}
var encoder = TokenClassifierEncoderFactory.Create(
modelDirectory,
NullLogger.Instance);
encoder.Should().BeOfType<BertWordPieceEncoder>();
encoder.IsAvailable.Should().BeTrue();
}
private static string ResolveTamilModelDirectory()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, "models", "ta");
if (Directory.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
return Path.Combine(Environment.CurrentDirectory, "models", "ta");
}
}

View File

@@ -24,6 +24,8 @@
<ItemGroup>
<Compile Include="..\TestSupport.Shared\RealNerModelPaths.cs" Link="TestSupport.Shared\RealNerModelPaths.cs" />
<Compile Include="..\TestSupport.Shared\RealNerModelFixture.cs" Link="TestSupport.Shared\RealNerModelFixture.cs" />
<Compile Include="..\TestSupport.Shared\RealTamilNerModelPaths.cs" Link="TestSupport.Shared\RealTamilNerModelPaths.cs" />
<Compile Include="..\TestSupport.Shared\RealTamilNerModelFixture.cs" Link="TestSupport.Shared\RealTamilNerModelFixture.cs" />
</ItemGroup>
<ItemGroup>

View File

@@ -6,12 +6,12 @@ using PiiRedaction.Infrastructure.Onnx;
namespace PiiRedaction.Tests.Shared;
/// <summary>
/// Reuses a single <see cref="OnnxNerModelRunner"/> per fixture for performance.
/// Reuses a single <see cref="EnglishOnnxNerRunner"/> per fixture for performance.
/// Skips all tests in the class when the ONNX model is missing or cannot be loaded.
/// </summary>
public abstract class RealNerModelFixture
{
protected OnnxNerModelRunner Runner { get; private set; } = null!;
protected EnglishOnnxNerRunner Runner { get; private set; } = null!;
protected string ModelPath { get; private set; } = null!;
@@ -24,8 +24,12 @@ public abstract class RealNerModelFixture
Assert.Ignore(RealNerModelPaths.ModelMissingMessage);
}
var options = Options.Create(new PiiRedactionOptions { OnnxModelPath = ModelPath });
Runner = new OnnxNerModelRunner(options, NullLogger<OnnxNerModelRunner>.Instance);
var options = Options.Create(new PiiRedactionOptions
{
EnglishOnnxModelPath = ModelPath,
OnnxModelPath = ModelPath
});
Runner = new EnglishOnnxNerRunner(options, NullLogger<EnglishOnnxNerRunner>.Instance);
if (!Runner.IsModelAvailable)
{

View File

@@ -7,16 +7,23 @@ public static class RealNerModelPaths
public static string ResolveRepoModelPath()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
foreach (var relativePath in new[]
{
Path.Combine("models", "en", "ner-model.onnx"),
Path.Combine("models", "ner-model.onnx")
})
{
var candidate = Path.Combine(directory.FullName, "models", "ner-model.onnx");
if (File.Exists(candidate))
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
return candidate;
}
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
directory = directory.Parent;
}
}
return Path.Combine(Environment.CurrentDirectory, "models", "ner-model.onnx");

View File

@@ -0,0 +1,69 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using PiiRedaction.Core.Configuration;
using PiiRedaction.Infrastructure.Onnx;
namespace PiiRedaction.Tests.Shared;
/// <summary>
/// Loads English and Tamil ONNX runners and exposes a <see cref="RoutingOnnxNerModelRunner"/>.
/// Skips when either model is missing or cannot be loaded.
/// </summary>
public abstract class RealRoutingNerModelFixture
{
protected RoutingOnnxNerModelRunner Runner { get; private set; } = null!;
protected EnglishOnnxNerRunner EnglishRunner { get; private set; } = null!;
protected TamilOnnxNerRunner TamilRunner { get; private set; } = null!;
[OneTimeSetUp]
public void OneTimeSetUpRoutingModels()
{
var englishPath = RealNerModelPaths.ResolveRepoModelPath();
if (!File.Exists(englishPath))
{
Assert.Ignore(RealNerModelPaths.ModelMissingMessage);
}
var tamilPath = RealTamilNerModelPaths.ResolveRepoModelPath();
if (!File.Exists(tamilPath))
{
Assert.Ignore(RealTamilNerModelPaths.ModelMissingMessage);
}
var options = Options.Create(new PiiRedactionOptions
{
EnglishOnnxModelPath = englishPath,
OnnxModelPath = englishPath,
TamilOnnxModelPath = tamilPath,
EnableTamilNer = true
});
EnglishRunner = new EnglishOnnxNerRunner(options, NullLogger<EnglishOnnxNerRunner>.Instance);
TamilRunner = new TamilOnnxNerRunner(options, NullLogger<TamilOnnxNerRunner>.Instance);
if (!EnglishRunner.IsModelAvailable)
{
EnglishRunner.Dispose();
TamilRunner.Dispose();
Assert.Ignore(RealNerModelPaths.ModelMissingMessage);
}
if (!TamilRunner.IsModelAvailable)
{
EnglishRunner.Dispose();
TamilRunner.Dispose();
Assert.Ignore(RealTamilNerModelPaths.ModelMissingMessage);
}
Runner = new RoutingOnnxNerModelRunner(EnglishRunner, TamilRunner, options);
}
[OneTimeTearDown]
public void OneTimeTearDownRoutingModels()
{
EnglishRunner?.Dispose();
TamilRunner?.Dispose();
}
}

View File

@@ -0,0 +1,45 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using PiiRedaction.Core.Configuration;
using PiiRedaction.Infrastructure.Onnx;
namespace PiiRedaction.Tests.Shared;
/// <summary>
/// Reuses a single <see cref="TamilOnnxNerRunner"/> per fixture for performance.
/// Skips all tests in the class when the Tamil ONNX model is missing or cannot be loaded.
/// </summary>
public abstract class RealTamilNerModelFixture
{
protected TamilOnnxNerRunner Runner { get; private set; } = null!;
protected string ModelPath { get; private set; } = null!;
[OneTimeSetUp]
public void OneTimeSetUpTamilModel()
{
ModelPath = RealTamilNerModelPaths.ResolveRepoModelPath();
if (!File.Exists(ModelPath))
{
Assert.Ignore(RealTamilNerModelPaths.ModelMissingMessage);
}
var options = Options.Create(new PiiRedactionOptions
{
TamilOnnxModelPath = ModelPath
});
Runner = new TamilOnnxNerRunner(options, NullLogger<TamilOnnxNerRunner>.Instance);
if (!Runner.IsModelAvailable)
{
Runner.Dispose();
Assert.Ignore(RealTamilNerModelPaths.ModelMissingMessage);
}
}
[OneTimeTearDown]
public void OneTimeTearDownTamilModel()
{
Runner?.Dispose();
}
}

View File

@@ -0,0 +1,25 @@
namespace PiiRedaction.Tests.Shared;
public static class RealTamilNerModelPaths
{
public const string ModelMissingMessage =
"Tamil ONNX model not found. Run scripts/download-tamil-ner-model.ps1 from the repository root.";
public static string ResolveRepoModelPath()
{
const string relativePath = "models/ta/model.onnx";
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
return Path.Combine(Environment.CurrentDirectory, relativePath);
}
}