Add PII redaction POC for secure LLM prompting.

Implements detect-redact-sanitize pipeline with regex, domain rules, and ONNX NER before the LLM boundary, plus NUnit tests and Xenovex push documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Bilal Nazer Ali
2026-07-07 13:05:07 +05:30
commit dfc81dea28
60 changed files with 3283 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
using FluentAssertions;
using PiiRedaction.Core.Abstractions;
using PiiRedaction.Core.Detection;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Tests.Detection;
[TestFixture]
public sealed class CompositePiiDetectorTests
{
[Test]
public void Detect_AdjacentSpans_KeepsBothEntities()
{
var composite = new CompositePiiDetector([new FixedDetector(
new PiiEntity(PiiEntityType.Email, "a@b.co", 0, 6, PiiDetectionSource.Regex),
new PiiEntity(PiiEntityType.Phone, "9876543210", 6, 10, PiiDetectionSource.Regex))]);
var entities = composite.Detect("abcdef9876543210padding");
entities.Should().HaveCount(2);
}
[Test]
public void Detect_NestedSpan_KeepsLongerSpan()
{
var composite = new CompositePiiDetector([new FixedDetector(
new PiiEntity(PiiEntityType.Aadhaar, "123456789012", 0, 12, PiiDetectionSource.Regex),
new PiiEntity(PiiEntityType.Phone, "4567890123", 2, 10, PiiDetectionSource.Regex))]);
var entities = composite.Detect("123456789012");
entities.Should().ContainSingle(entity => entity.Type == PiiEntityType.Aadhaar);
}
[Test]
public void Detect_OverlappingSameStart_LongerSpanWins()
{
var composite = new CompositePiiDetector([new FixedDetector(
new PiiEntity(PiiEntityType.LoanNumber, "LN-456789", 0, 9, PiiDetectionSource.Domain),
new PiiEntity(PiiEntityType.Phone, "456789", 3, 6, PiiDetectionSource.Regex))]);
var entities = composite.Detect("LN-456789");
entities.Should().ContainSingle(entity => entity.Type == PiiEntityType.LoanNumber);
}
[Test]
public void Detect_OverlappingDifferentPriority_DomainBeatsRegex()
{
var composite = new CompositePiiDetector([new FixedDetector(
new PiiEntity(PiiEntityType.LoanNumber, "LN-456789", 0, 9, PiiDetectionSource.Domain),
new PiiEntity(PiiEntityType.Phone, "456789", 0, 6, PiiDetectionSource.Regex))]);
var entities = composite.Detect("LN-456789");
entities.Should().ContainSingle(entity =>
entity.Type == PiiEntityType.LoanNumber &&
entity.Source == PiiDetectionSource.Domain);
}
[Test]
public void Detect_OverlappingPriority_RegexBeatsNer()
{
var composite = new CompositePiiDetector([new FixedDetector(
new PiiEntity(PiiEntityType.Email, "a@b.co", 0, 6, PiiDetectionSource.Regex),
new PiiEntity(PiiEntityType.Person, "a@b", 0, 3, PiiDetectionSource.Ner))]);
var entities = composite.Detect("a@b.co");
entities.Should().ContainSingle(entity => entity.Source == PiiDetectionSource.Regex);
}
[Test]
public void Detect_OverlappingPriority_DomainBeatsNer()
{
var composite = new CompositePiiDetector([new FixedDetector(
new PiiEntity(PiiEntityType.LoanNumber, "LN-456789", 0, 9, PiiDetectionSource.Domain),
new PiiEntity(PiiEntityType.Person, "LN-456", 0, 6, PiiDetectionSource.Ner))]);
var entities = composite.Detect("LN-456789");
entities.Should().ContainSingle(entity => entity.Source == PiiDetectionSource.Domain);
}
[Test]
public void Detect_DuplicateOverlappingSpan_KeepsFirstAccepted()
{
var composite = new CompositePiiDetector([new FixedDetector(
new PiiEntity(PiiEntityType.Phone, "9876543210", 0, 10, PiiDetectionSource.Regex),
new PiiEntity(PiiEntityType.Phone, "9876543210", 0, 10, PiiDetectionSource.Regex))]);
var entities = composite.Detect("9876543210");
entities.Should().HaveCount(1);
}
[Test]
public void Detect_AadhaarWithEmbeddedPhone_PrefersAadhaarSpan()
{
var detector = new CompositePiiDetector(
[
new DomainRulePiiDetector(),
new RegexPiiDetector()
]);
var entities = detector.Detect("Aadhaar 987654321012 phone 9876543210.");
entities.Should().Contain(entity => entity.Type == PiiEntityType.Aadhaar && entity.Value == "987654321012");
entities.Should().Contain(entity => entity.Type == PiiEntityType.Phone && entity.Value == "9876543210");
entities.Count(entity => entity.Type == PiiEntityType.Phone).Should().Be(1);
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void Detect_InvalidInput_ThrowsArgumentException(string? text)
{
var composite = new CompositePiiDetector([new RegexPiiDetector()]);
var action = () => composite.Detect(text!);
action.Should().Throw<ArgumentException>();
}
private sealed class FixedDetector(params PiiEntity[] entities) : IPiiDetector
{
public IReadOnlyList<PiiEntity> Detect(string text) => entities;
}
}

View File

@@ -0,0 +1,70 @@
using FluentAssertions;
using PiiRedaction.Core.Detection;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Tests.Detection;
[TestFixture]
public sealed class DomainRulePiiDetectorTests
{
private readonly DomainRulePiiDetector _detector = new();
[Test]
public void Detect_PositiveLoanNumber_ReturnsValueOnly()
{
var entities = _detector.Detect("Loan LN-456789 active.");
entities.Should().ContainSingle(entity =>
entity.Type == PiiEntityType.LoanNumber &&
entity.Value == "LN-456789" &&
entity.Source == PiiDetectionSource.Domain);
}
[Test]
public void Detect_PositiveCustomerId_ReturnsEntity()
{
var entities = _detector.Detect("CustomerId CID-1234 found.");
entities.Should().ContainSingle(entity =>
entity.Type == PiiEntityType.CustomerId &&
entity.Value == "CID-1234");
}
[Test]
public void Detect_PositiveAccountNumber_ReturnsEntity()
{
var entities = _detector.Detect("Account ACC-123456 open.");
entities.Should().ContainSingle(entity =>
entity.Type == PiiEntityType.AccountNumber &&
entity.Value == "ACC-123456");
}
[TestCase("LN-12345")]
[TestCase("XLN-456789")]
[TestCase("CID-123")]
[TestCase("ACC-12345")]
public void Detect_InvalidDomainIds_ReturnsEmpty(string text)
{
_detector.Detect(text).Should().BeEmpty();
}
[Test]
public void Detect_LoanNumberLabel_PreservesLabelInSurroundingText()
{
const string text = "LoanNumber LN-456789 end.";
var entity = _detector.Detect(text).Single();
entity.Value.Should().Be("LN-456789");
entity.StartIndex.Should().Be("LoanNumber ".Length);
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void Detect_InvalidInput_ThrowsArgumentException(string? text)
{
var action = () => _detector.Detect(text!);
action.Should().Throw<ArgumentException>();
}
}

View File

@@ -0,0 +1,54 @@
using FluentAssertions;
using PiiRedaction.Core.Detection;
using PiiRedaction.Core.Models;
using PiiRedaction.Core.Tests.TestSupport;
namespace PiiRedaction.Core.Tests.Detection;
[TestFixture]
public sealed class OnnxNerPiiDetectorTests
{
[Test]
public void Detect_ModelAvailable_ReturnsRunnerEntities()
{
var runner = new FakeOnnxNerModelRunner
{
IsModelAvailable = true,
EntitiesToReturn =
[
new PiiEntity(PiiEntityType.Person, "Onnx Person", 0, 11, PiiDetectionSource.Ner)
]
};
var detector = new OnnxNerPiiDetector(runner);
var entities = detector.Detect("Any text");
entities.Should().ContainSingle(entity => entity.Value == "Onnx Person");
runner.LastPredictedText.Should().Be("Any text");
}
[Test]
public void Detect_ModelUnavailable_ReturnsEmpty()
{
var detector = CreateDetector(modelAvailable: false);
var entities = detector.Detect("Customer Ravi Kumar with email test@x.com.");
entities.Should().BeEmpty();
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void Detect_InvalidInput_ThrowsArgumentException(string? text)
{
var detector = CreateDetector(modelAvailable: false);
var action = () => detector.Detect(text!);
action.Should().Throw<ArgumentException>();
}
private static OnnxNerPiiDetector CreateDetector(bool modelAvailable)
{
var runner = new FakeOnnxNerModelRunner { IsModelAvailable = modelAvailable };
return new OnnxNerPiiDetector(runner);
}
}

View File

@@ -0,0 +1,92 @@
using FluentAssertions;
using PiiRedaction.Core.Detection;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Tests.Detection;
[TestFixture]
public sealed class RegexPiiDetectorTests
{
private readonly RegexPiiDetector _detector = new();
[Test]
public void Detect_PositiveEmail_ReturnsEntity()
{
const string text = "Contact ravi.kumar@gmail.com now.";
var entities = _detector.Detect(text);
entities.Should().ContainSingle(entity =>
entity.Type == PiiEntityType.Email &&
entity.Value == "ravi.kumar@gmail.com" &&
entity.Source == PiiDetectionSource.Regex);
}
[Test]
public void Detect_PositivePhone_ReturnsTenDigitEntity()
{
var entities = _detector.Detect("Call 9876543210 today.");
entities.Should().ContainSingle(entity =>
entity.Type == PiiEntityType.Phone &&
entity.Value == "9876543210" &&
entity.Source == PiiDetectionSource.Regex);
}
[TestCase("Aadhaar 1234 5678 9012 linked.", "1234 5678 9012")]
[TestCase("Aadhaar 123456789012 linked.", "123456789012")]
public void Detect_PositiveAadhaar_ReturnsEntity(string text, string expectedValue)
{
var entities = _detector.Detect(text);
entities.Should().ContainSingle(entity =>
entity.Type == PiiEntityType.Aadhaar &&
entity.Value == expectedValue);
}
[Test]
public void Detect_PositivePan_ReturnsUppercaseEntity()
{
var entities = _detector.Detect("PAN ABCDE1234F verified.");
entities.Should().ContainSingle(entity => entity.Type == PiiEntityType.Pan);
entities[0].Value.Should().MatchRegex("^[A-Z]{5}\\d{4}[A-Z]$");
}
[Test]
public void Detect_PositiveCreditCard_ReturnsEntity()
{
var entities = _detector.Detect("Card 4111-1111-1111-1111 used.");
entities.Should().ContainSingle(entity => entity.Type == PiiEntityType.CreditCard);
}
[TestCase("not-an-email")]
[TestCase("@missing.com")]
[TestCase("pan abcde1234f")]
[TestCase("Number 987654321")]
public void Detect_NegativePatterns_ReturnsNoMatch(string text)
{
_detector.Detect(text).Should().BeEmpty();
}
[Test]
public void Detect_EmailSpan_HasCorrectIndices()
{
const string text = "Email ravi@test.com end.";
var entities = _detector.Detect(text);
var email = entities.Single(entity => entity.Type == PiiEntityType.Email);
email.StartIndex.Should().Be(6);
email.Length.Should().Be("ravi@test.com".Length);
text[email.StartIndex..email.EndIndex].Should().Be("ravi@test.com");
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void Detect_InvalidInput_ThrowsArgumentException(string? text)
{
var action = () => _detector.Detect(text!);
action.Should().Throw<ArgumentException>();
}
}

View File

@@ -0,0 +1,25 @@
using FluentAssertions;
using PiiRedaction.Core.Models;
using PiiRedaction.Core.Tests.TestSupport;
namespace PiiRedaction.Core.Tests.Integration;
[TestFixture]
public sealed class GoldenPromptTests
{
[TestCaseSource(typeof(PromptScenarioCatalog), nameof(PromptScenarioCatalog.AllScenarios))]
public void Sanitize_PromptScenario_ProducesExpectedOutput(PromptScenario scenario)
{
var sanitizer = ProductionPipelineFactory.CreateForScenario(scenario);
var result = sanitizer.Sanitize(new SanitizationRequest(scenario.Prompt));
result.SanitizedPrompt.Should().Be(scenario.ExpectedSanitized, because: scenario.Name);
result.DetectedEntities.Select(entity => entity.Type)
.Should().BeEquivalentTo(scenario.ExpectedTypes, because: scenario.Name);
foreach (var forbidden in scenario.MustNotContainInSanitized)
{
result.SanitizedPrompt.Should().NotContain(forbidden, because: scenario.Name);
}
}
}

View File

@@ -0,0 +1,52 @@
using FluentAssertions;
using PiiRedaction.Core.Abstractions;
using PiiRedaction.Core.Models;
using PiiRedaction.Core.Tests.TestSupport;
using PiiRedaction.Infrastructure.Llm;
namespace PiiRedaction.Core.Tests.Integration;
[TestFixture]
public sealed class LlmBoundaryTests
{
[Test]
public async Task SendPromptAsync_DoesNotTransmitOriginalPii()
{
var capturingClient = new CapturingChatClient();
ILlmPromptService service = new MockLlmPromptService(capturingClient);
const string sanitized = "Customer <PERSON_1> with email <EMAIL_1>.";
await service.SendPromptAsync(sanitized);
capturingClient.LastUserMessage.Should().Be(sanitized);
capturingClient.LastUserMessage.Should().NotContain("ravi.kumar@gmail.com");
capturingClient.LastUserMessage.Should().NotContain("Ravi Kumar");
}
[Test]
public async Task SendPromptAsync_AfterSanitization_OnlyPlaceholdersReachLlm()
{
var capturingClient = new CapturingChatClient();
var sanitizer = ProductionPipelineFactory.Create().Sanitizer;
ILlmPromptService service = new MockLlmPromptService(capturingClient);
const string prompt = "Email ravi.kumar@gmail.com please.";
var result = sanitizer.Sanitize(new SanitizationRequest(prompt));
result.SanitizedPrompt.Should().Contain("<EMAIL_1>");
await service.SendPromptAsync(result.SanitizedPrompt);
capturingClient.LastUserMessage.Should().Contain("<EMAIL_1>");
capturingClient.LastUserMessage.Should().NotContain("ravi.kumar@gmail.com");
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void SendPromptAsync_InvalidSanitizedPrompt_ThrowsArgumentException(string? prompt)
{
ILlmPromptService service = new MockLlmPromptService(new MockChatClient());
var action = async () => await service.SendPromptAsync(prompt!);
action.Should().ThrowAsync<ArgumentException>();
}
}

View File

@@ -0,0 +1,127 @@
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 the real ONNX NER model (no fakes).
/// </summary>
[TestFixture]
[Category("RealModel")]
public sealed class RealNerPipelineTests : RealNerModelFixture
{
private IPromptSanitizer _sanitizer = null!;
[OneTimeSetUp]
public void OneTimeSetUpPipeline()
{
_sanitizer = ProductionPipelineFactory.CreateWithRealModel(Runner);
}
[Test]
public void Sanitize_FullFinancialWithCustomer_RedactsAllPiiTypes()
{
const string prompt =
"Customer Ravi Kumar with email ravi.kumar@gmail.com and phone 9876543210 has LoanNumber LN-456789 and PAN ABCDE1234F. Please summarize this customer issue.";
var result = _sanitizer.Sanitize(new SanitizationRequest(prompt));
result.SanitizedPrompt.Should().Be(
"Customer <PERSON_1> with email <EMAIL_1> and phone <PHONE_1> has LoanNumber <LOAN_NUMBER_1> and PAN <PAN_1>. Please summarize this customer issue.");
result.SanitizedPrompt.Should().NotContainAny("Ravi Kumar", "ravi.kumar@gmail.com", "9876543210", "LN-456789", "ABCDE1234F");
result.DetectedEntities.Should().Contain(entity =>
entity.Type == PiiEntityType.Person && entity.Source == PiiDetectionSource.Ner);
}
[Test]
public void Sanitize_TwoCustomersInOnePrompt_RedactsBothPeople()
{
const string prompt = "Customer Ravi Kumar and Customer Priya Nair disputed the same charge.";
var result = _sanitizer.Sanitize(new SanitizationRequest(prompt));
result.SanitizedPrompt.Should().Contain("<PERSON_1>");
result.SanitizedPrompt.Should().Contain("<PERSON_2>");
result.SanitizedPrompt.Should().NotContainAny("Ravi Kumar", "Priya Nair");
result.DetectedEntities.Count(entity => entity.Type == PiiEntityType.Person).Should().BeGreaterThanOrEqualTo(2);
}
[Test]
public void Sanitize_NoPiiCleanTicket_PassesThroughUnchanged()
{
const string prompt = "What is the status of ticket TKT-99887 and when will the API maintenance end?";
var result = _sanitizer.Sanitize(new SanitizationRequest(prompt));
result.SanitizedPrompt.Should().Be(prompt);
result.DetectedEntities.Should().BeEmpty();
}
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.10.0" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" Version="10.7.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NUnit" Version="4.3.2" />
<PackageReference Include="NUnit.Analyzers" Version="4.7.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\TestSupport.Shared\RealNerModelPaths.cs" Link="TestSupport.Shared\RealNerModelPaths.cs" />
<Compile Include="..\TestSupport.Shared\RealNerModelFixture.cs" Link="TestSupport.Shared\RealNerModelFixture.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\PiiRedaction.Core\PiiRedaction.Core.csproj" />
<ProjectReference Include="..\..\src\PiiRedaction.Infrastructure\PiiRedaction.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,134 @@
using FluentAssertions;
using PiiRedaction.Core.Models;
using PiiRedaction.Core.Redaction;
namespace PiiRedaction.Core.Tests.Redaction;
[TestFixture]
public sealed class PlaceholderPiiRedactorTests
{
private readonly PlaceholderPiiRedactor _redactor = new();
[Test]
public void Redact_NoEntities_ReturnsOriginalTextAndEmptyMap()
{
const string text = "No PII here.";
var result = _redactor.Redact(text, []);
result.SanitizedText.Should().Be(text);
result.PlaceholderMap.Should().BeEmpty();
}
[Test]
public void Redact_SingleEntity_ReplacesWithTypedPlaceholder()
{
const string text = "Email ravi@test.com end.";
var entities = new[] { Entity(PiiEntityType.Email, "ravi@test.com", 6) };
var result = _redactor.Redact(text, entities);
result.SanitizedText.Should().Be("Email <EMAIL_1> end.");
result.PlaceholderMap["<EMAIL_1>"].Should().Be("ravi@test.com");
}
[Test]
public void Redact_DuplicateSameTypeAndValue_ReusesPlaceholder()
{
const string text = "a@b.co and a@b.co";
var entities = new[]
{
Entity(PiiEntityType.Email, "a@b.co", 0),
Entity(PiiEntityType.Email, "a@b.co", 11)
};
var result = _redactor.Redact(text, entities);
result.SanitizedText.Should().Be("<EMAIL_1> and <EMAIL_1>");
result.PlaceholderMap.Should().HaveCount(1);
}
[Test]
public void Redact_MultipleTypes_AssignsIndependentCounters()
{
const string text = "9876543210 ravi@test.com";
var entities = new[]
{
Entity(PiiEntityType.Phone, "9876543210", 0),
Entity(PiiEntityType.Email, "ravi@test.com", 11)
};
var result = _redactor.Redact(text, entities);
result.SanitizedText.Should().Be("<PHONE_1> <EMAIL_1>");
}
[Test]
public void Redact_MultipleSameTypeDifferentValues_IncrementsCounter()
{
const string text = "Customer Ravi Kumar and Customer Priya Nair";
var entities = new[]
{
Entity(PiiEntityType.Person, "Ravi Kumar", 9),
Entity(PiiEntityType.Person, "Priya Nair", 33)
};
var result = _redactor.Redact(text, entities);
result.SanitizedText.Should().Be("Customer <PERSON_2> and Customer <PERSON_1>");
result.PlaceholderMap.Should().HaveCount(2);
}
[Test]
public void Redact_EntityTypes_UseTypedPlaceholderPrefixes()
{
(PiiEntityType Type, string Prefix)[] cases =
[
(PiiEntityType.Person, "PERSON"),
(PiiEntityType.Email, "EMAIL"),
(PiiEntityType.LoanNumber, "LOAN_NUMBER")
];
foreach (var (type, prefix) in cases)
{
const string value = "VALUE";
var text = $"start {value} end";
var result = _redactor.Redact(text, [Entity(type, value, 6)]);
result.SanitizedText.Should().Contain($"<{prefix}_1>", because: type.ToString());
}
}
[Test]
public void Redact_RightToLeftReplacement_PreservesCorrectOutput()
{
const string text = "AA BB CC";
var entities = new[]
{
Entity(PiiEntityType.Phone, "AA", 0),
Entity(PiiEntityType.Email, "BB", 3),
Entity(PiiEntityType.Pan, "CC", 6)
};
var result = _redactor.Redact(text, entities);
result.SanitizedText.Should().Be("<PHONE_1> <EMAIL_1> <PAN_1>");
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void Redact_InvalidText_ThrowsArgumentException(string? text)
{
var action = () => _redactor.Redact(text!, []);
action.Should().Throw<ArgumentException>();
}
[Test]
public void Redact_NullEntities_ThrowsArgumentNullException()
{
var action = () => _redactor.Redact("text", null!);
action.Should().Throw<ArgumentNullException>();
}
private static PiiEntity Entity(PiiEntityType type, string value, int start) =>
new(type, value, start, value.Length, PiiDetectionSource.Regex);
}

View File

@@ -0,0 +1,26 @@
using FluentAssertions;
using PiiRedaction.Core.Sanitization;
using PiiRedaction.Core.Tests.TestSupport;
namespace PiiRedaction.Core.Tests.Sanitization;
[TestFixture]
public sealed class PromptSanitizerTests
{
[Test]
public void Sanitize_NullRequest_ThrowsArgumentNullException()
{
var sanitizer = ProductionPipelineFactory.Create().Sanitizer;
var action = () => sanitizer.Sanitize(null!);
action.Should().Throw<ArgumentNullException>();
}
[TestCase("")]
[TestCase(" ")]
public void Sanitize_WhitespacePrompt_ThrowsArgumentException(string prompt)
{
var sanitizer = ProductionPipelineFactory.Create().Sanitizer;
var action = () => sanitizer.Sanitize(new(prompt));
action.Should().Throw<ArgumentException>();
}
}

View File

@@ -0,0 +1,38 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.AI;
namespace PiiRedaction.Core.Tests.TestSupport;
public sealed class CapturingChatClient : IChatClient
{
public string? LastUserMessage { get; private set; }
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
LastUserMessage = messages.LastOrDefault(message => message.Role == ChatRole.User)?.Text;
var response = new ChatResponse(new ChatMessage(
ChatRole.Assistant,
$"Captured {LastUserMessage?.Length ?? 0} chars."));
return Task.FromResult(response);
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var response = await GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
yield return new ChatResponseUpdate(ChatRole.Assistant, response.Messages.Last().Text);
}
public object? GetService(Type serviceType, object? serviceKey = null) => null;
public void Dispose()
{
}
}

View File

@@ -0,0 +1,19 @@
using PiiRedaction.Core.Detection;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Tests.TestSupport;
public sealed class FakeOnnxNerModelRunner : IOnnxNerModelRunner
{
public bool IsModelAvailable { get; set; }
public IReadOnlyList<PiiEntity> EntitiesToReturn { get; set; } = [];
public string? LastPredictedText { get; private set; }
public IReadOnlyList<PiiEntity> PredictEntities(string text)
{
LastPredictedText = text;
return EntitiesToReturn;
}
}

View File

@@ -0,0 +1,33 @@
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Tests.TestSupport;
public static class NerEntityBuilder
{
public static IReadOnlyList<PiiEntity> BuildFromScenario(PromptScenario scenario)
{
var entities = new List<PiiEntity>();
foreach (var (type, value) in scenario.ExpectedTypes.Zip(scenario.MustNotContainInSanitized))
{
if (type != PiiEntityType.Person)
{
continue;
}
var searchStart = 0;
while ((searchStart = scenario.Prompt.IndexOf(value, searchStart, StringComparison.Ordinal)) >= 0)
{
entities.Add(new PiiEntity(
PiiEntityType.Person,
value,
searchStart,
value.Length,
PiiDetectionSource.Ner));
searchStart += value.Length;
}
}
return entities;
}
}

View File

@@ -0,0 +1,49 @@
using PiiRedaction.Core.Abstractions;
using PiiRedaction.Core.Detection;
using PiiRedaction.Core.Redaction;
using PiiRedaction.Core.Sanitization;
namespace PiiRedaction.Core.Tests.TestSupport;
public static class ProductionPipelineFactory
{
public static IPromptSanitizer CreateWithRealModel(IOnnxNerModelRunner runner) =>
new PromptSanitizer(CreateCompositeDetector(runner), new PlaceholderPiiRedactor());
public static IPiiDetector CreateCompositeDetector(IOnnxNerModelRunner runner) =>
new CompositePiiDetector(
[
new DomainRulePiiDetector(),
new RegexPiiDetector(),
new OnnxNerPiiDetector(runner)
]);
public static (IPromptSanitizer Sanitizer, FakeOnnxNerModelRunner NerRunner) Create(
bool modelAvailable = false,
IReadOnlyList<Models.PiiEntity>? nerEntities = null)
{
var nerRunner = CreateNerRunner(modelAvailable, nerEntities);
var sanitizer = new PromptSanitizer(CreateCompositeDetector(nerRunner), new PlaceholderPiiRedactor());
return (sanitizer, nerRunner);
}
public static IPromptSanitizer CreateForScenario(PromptScenario scenario)
{
var nerEntities = NerEntityBuilder.BuildFromScenario(scenario);
return Create(modelAvailable: nerEntities.Count > 0, nerEntities: nerEntities).Sanitizer;
}
public static IPiiDetector CreateCompositeDetector(
bool modelAvailable = false,
IReadOnlyList<Models.PiiEntity>? nerEntities = null) =>
CreateCompositeDetector(CreateNerRunner(modelAvailable, nerEntities));
private static FakeOnnxNerModelRunner CreateNerRunner(
bool modelAvailable,
IReadOnlyList<Models.PiiEntity>? nerEntities) =>
new()
{
IsModelAvailable = modelAvailable,
EntitiesToReturn = nerEntities ?? []
};
}

View File

@@ -0,0 +1,10 @@
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Tests.TestSupport;
public sealed record PromptScenario(
string Name,
string Prompt,
string ExpectedSanitized,
PiiEntityType[] ExpectedTypes,
string[] MustNotContainInSanitized);

View File

@@ -0,0 +1,68 @@
using NUnit.Framework;
using PiiRedaction.Core.Models;
namespace PiiRedaction.Core.Tests.TestSupport;
/// <summary>
/// Focused end-to-end scenarios that exercise the full sanitizer pipeline beyond what unit tests cover in isolation.
/// </summary>
public static class PromptScenarioCatalog
{
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 CanonicalScenario();
yield return MultiRegexScenario();
yield return DuplicatePeopleScenario();
yield return OverlapScenario();
yield return NoPiiScenario();
}
private static PromptScenario CanonicalScenario()
{
const string prompt =
"Customer Ravi Kumar with email ravi.kumar@gmail.com and phone 9876543210 has LoanNumber LN-456789 and PAN ABCDE1234F. Please summarize this customer issue.";
return new PromptScenario(
"Canonical_DemoPrompt",
prompt,
"Customer <PERSON_1> with email <EMAIL_1> and phone <PHONE_1> has LoanNumber <LOAN_NUMBER_1> and PAN <PAN_1>. Please summarize this customer issue.",
[PiiEntityType.Person, PiiEntityType.Email, PiiEntityType.Phone, PiiEntityType.LoanNumber, PiiEntityType.Pan],
["Ravi Kumar", "ravi.kumar@gmail.com", "9876543210", "LN-456789", "ABCDE1234F"]);
}
private static PromptScenario MultiRegexScenario() => new(
"Full_AllRegexTypes",
"Email a@b.co phone 9001234567 PAN ABCDE1234F aadhaar 1234 5678 9012 card 4111-1111-1111-1111.",
"Email <EMAIL_1> phone <PHONE_1> PAN <PAN_1> aadhaar <AADHAAR_1> card <CREDIT_CARD_1>.",
[PiiEntityType.Email, PiiEntityType.Phone, PiiEntityType.Pan, PiiEntityType.Aadhaar, PiiEntityType.CreditCard],
["a@b.co", "9001234567", "ABCDE1234F", "1234 5678 9012", "4111-1111-1111-1111"]);
private static PromptScenario DuplicatePeopleScenario() => new(
"Duplicate_TwoPeople",
"Customer Ravi Kumar and Customer Priya Nair.",
"Customer <PERSON_2> and Customer <PERSON_1>.",
[PiiEntityType.Person, PiiEntityType.Person],
["Ravi Kumar", "Priya Nair"]);
private static PromptScenario OverlapScenario() => new(
"Overlap_AadhaarAndPhone",
"Aadhaar 987654321012 and phone 9876543210.",
"Aadhaar <AADHAAR_1> and phone <PHONE_1>.",
[PiiEntityType.Aadhaar, PiiEntityType.Phone],
["9876543210"]);
private static PromptScenario NoPiiScenario() => new(
"Negative_NoPii",
"Please summarize the general policy on refunds.",
"Please summarize the general policy on refunds.",
[],
[]);
}

View File

@@ -0,0 +1,62 @@
using System.Runtime.CompilerServices;
using FluentAssertions;
using Microsoft.Extensions.AI;
using PiiRedaction.Infrastructure.Llm;
namespace PiiRedaction.Infrastructure.Tests.Llm;
[TestFixture]
public sealed class MockLlmPromptServiceTests
{
[Test]
public async Task SendPromptAsync_ReturnsAssistantText()
{
var service = new MockLlmPromptService(new MockChatClient());
const string prompt = "Sanitized <EMAIL_1> content.";
var response = await service.SendPromptAsync(prompt);
response.Should().Contain("Mock LLM Response");
response.Should().Contain($"{prompt.Length} chars");
}
[Test]
public async Task SendPromptAsync_UsesUserRoleForOutboundMessage()
{
var capturingClient = new CapturingChatClient();
var service = new MockLlmPromptService(capturingClient);
await service.SendPromptAsync("Only placeholders <PHONE_1>.");
capturingClient.LastUserMessage.Should().Be("Only placeholders <PHONE_1>.");
}
private sealed class CapturingChatClient : IChatClient
{
public string? LastUserMessage { get; private set; }
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
LastUserMessage = messages.LastOrDefault(message => message.Role == ChatRole.User)?.Text;
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "ok")));
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await GetResponseAsync(messages, options, cancellationToken);
yield break;
}
public object? GetService(Type serviceType, object? serviceKey = null) => null;
public void Dispose()
{
}
}
}

View File

@@ -0,0 +1,53 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using PiiRedaction.Core.Configuration;
using PiiRedaction.Core.Models;
using PiiRedaction.Infrastructure.Onnx;
namespace PiiRedaction.Infrastructure.Tests.Onnx;
[TestFixture]
public sealed class OnnxNerModelRunnerTests
{
[Test]
public void Constructor_MissingModel_IsModelUnavailable()
{
var path = Path.Combine(Path.GetTempPath(), $"missing-ner-{Guid.NewGuid():N}.onnx");
using var runner = CreateRunner(path);
runner.IsModelAvailable.Should().BeFalse();
}
[Test]
public void PredictEntities_WhenModelUnavailable_ReturnsEmpty()
{
var path = Path.Combine(Path.GetTempPath(), $"missing-ner-{Guid.NewGuid():N}.onnx");
using var runner = CreateRunner(path);
runner.PredictEntities("Customer Ravi Kumar").Should().BeEmpty();
}
[Test]
public void Constructor_InvalidModelFile_IsModelUnavailable()
{
var path = Path.Combine(Path.GetTempPath(), $"invalid-ner-{Guid.NewGuid():N}.onnx");
File.WriteAllText(path, "not-a-valid-onnx-model");
try
{
using var runner = CreateRunner(path);
runner.IsModelAvailable.Should().BeFalse();
}
finally
{
File.Delete(path);
}
}
private static OnnxNerModelRunner CreateRunner(string modelPath)
{
var options = Options.Create(new PiiRedactionOptions { OnnxModelPath = modelPath });
return new OnnxNerModelRunner(options, NullLogger<OnnxNerModelRunner>.Instance);
}
}

View File

@@ -0,0 +1,51 @@
using FluentAssertions;
using PiiRedaction.Core.Models;
using PiiRedaction.Tests.Shared;
namespace PiiRedaction.Infrastructure.Tests.Onnx;
[TestFixture]
[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(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);
}
}

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NUnit" Version="4.3.2" />
<PackageReference Include="NUnit.Analyzers" Version="4.7.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\TestSupport.Shared\RealNerModelPaths.cs" Link="TestSupport.Shared\RealNerModelPaths.cs" />
<Compile Include="..\TestSupport.Shared\RealNerModelFixture.cs" Link="TestSupport.Shared\RealNerModelFixture.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\PiiRedaction.Infrastructure\PiiRedaction.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\PiiRedaction.Core\PiiRedaction.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,42 @@
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="OnnxNerModelRunner"/> 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 string ModelPath { get; private set; } = null!;
[OneTimeSetUp]
public void OneTimeSetUpRealModel()
{
ModelPath = RealNerModelPaths.ResolveRepoModelPath();
if (!File.Exists(ModelPath))
{
Assert.Ignore(RealNerModelPaths.ModelMissingMessage);
}
var options = Options.Create(new PiiRedactionOptions { OnnxModelPath = ModelPath });
Runner = new OnnxNerModelRunner(options, NullLogger<OnnxNerModelRunner>.Instance);
if (!Runner.IsModelAvailable)
{
Runner.Dispose();
Assert.Ignore(RealNerModelPaths.ModelMissingMessage);
}
}
[OneTimeTearDown]
public void OneTimeTearDownRealModel()
{
Runner?.Dispose();
}
}

View File

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