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,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.",
|
||||
[],
|
||||
[]);
|
||||
}
|
||||
Reference in New Issue
Block a user