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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
35
tests/PiiRedaction.Core.Tests/PiiRedaction.Core.Tests.csproj
Normal file
35
tests/PiiRedaction.Core.Tests/PiiRedaction.Core.Tests.csproj
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 ?? []
|
||||
};
|
||||
}
|
||||
10
tests/PiiRedaction.Core.Tests/TestSupport/PromptScenario.cs
Normal file
10
tests/PiiRedaction.Core.Tests/TestSupport/PromptScenario.cs
Normal 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);
|
||||
@@ -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.",
|
||||
[],
|
||||
[]);
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
42
tests/TestSupport.Shared/RealNerModelFixture.cs
Normal file
42
tests/TestSupport.Shared/RealNerModelFixture.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
24
tests/TestSupport.Shared/RealNerModelPaths.cs
Normal file
24
tests/TestSupport.Shared/RealNerModelPaths.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user