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,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();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user