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