Surface NER routing provenance (ModelOrigin, NerModelsInvoked) through the pipeline and WPF harness so English/Tamil routing is observable during POC validation. Consolidate docs into solution-guide and add NER logs, topic filtering, and batch UI fixes in the test harness.

This commit is contained in:
Bilal Nazer Ali
2026-07-08 12:18:27 +05:30
parent cf8f5a7232
commit 81220c6ade
51 changed files with 1530 additions and 1463 deletions

View File

@@ -46,6 +46,36 @@ public sealed class OnnxNerPiiDetectorTests
action.Should().Throw<ArgumentException>();
}
[Test]
public void Detect_ModelAvailable_RecordsInvokedModels()
{
var runner = new FakeOnnxNerModelRunner
{
IsModelAvailable = true,
InvokedModelsToReturn = [NerModelOrigin.English, NerModelOrigin.Tamil]
};
var detector = new OnnxNerPiiDetector(runner);
detector.Detect("Mixed prompt");
detector.LastInvokedModels.Should().Equal(NerModelOrigin.English, NerModelOrigin.Tamil);
}
[Test]
public void Detect_ModelUnavailable_ClearsInvokedModels()
{
var runner = new FakeOnnxNerModelRunner
{
IsModelAvailable = false,
InvokedModelsToReturn = [NerModelOrigin.English]
};
var detector = new OnnxNerPiiDetector(runner);
detector.Detect("Any text");
detector.LastInvokedModels.Should().BeEmpty();
}
private static OnnxNerPiiDetector CreateDetector(bool modelAvailable)
{
var runner = new FakeOnnxNerModelRunner { IsModelAvailable = modelAvailable };

View File

@@ -32,7 +32,32 @@ public sealed class RealTamilPipelineTests : RealRoutingNerModelFixture
result.SanitizedPrompt.Should().Contain("<PERSON_1>");
result.SanitizedPrompt.Should().NotContain("ராஜேஷ்");
result.DetectedEntities.Should().Contain(entity =>
entity.Type == PiiEntityType.Person && entity.Source == PiiDetectionSource.Ner);
entity.Type == PiiEntityType.Person
&& entity.Source == PiiDetectionSource.Ner
&& entity.ModelOrigin == NerModelOrigin.Tamil);
result.NerModelsInvoked.Should().Equal(NerModelOrigin.Tamil);
}
[Test]
public void Sanitize_TanglishCustomer_InvokesEnglishNerOnly()
{
const string prompt = "Customer Senthil phone 9876543210 reported a failed UPI transfer.";
var result = _sanitizer.Sanitize(new SanitizationRequest(prompt));
result.NerModelsInvoked.Should().Equal(NerModelOrigin.English);
result.DetectedEntities.Should().Contain(entity =>
entity.Type == PiiEntityType.Person && entity.ModelOrigin == NerModelOrigin.English);
}
[Test]
public void Sanitize_MixedTamilEnglish_InvokesBothNerModels()
{
const string prompt = "வாடிக்கையாளர் Ravi Kumar phone 9876543210 disputed the charge.";
var result = _sanitizer.Sanitize(new SanitizationRequest(prompt));
result.NerModelsInvoked.Should().Equal(NerModelOrigin.English, NerModelOrigin.Tamil);
}
[Test]

View File

@@ -9,11 +9,13 @@ public sealed class FakeOnnxNerModelRunner : IOnnxNerModelRunner
public IReadOnlyList<PiiEntity> EntitiesToReturn { get; set; } = [];
public IReadOnlyList<NerModelOrigin> InvokedModelsToReturn { get; set; } = [NerModelOrigin.English];
public string? LastPredictedText { get; private set; }
public IReadOnlyList<PiiEntity> PredictEntities(string text)
public NerPredictionResult PredictEntities(string text)
{
LastPredictedText = text;
return EntitiesToReturn;
return new NerPredictionResult(EntitiesToReturn, InvokedModelsToReturn);
}
}

View File

@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using PiiRedaction.Core.Abstractions;
using PiiRedaction.Core.Configuration;
@@ -20,7 +21,8 @@ public static class ProductionPipelineFactory
CreateWithRealModel(new RoutingOnnxNerModelRunner(
englishRunner,
tamilRunner,
options ?? Options.Create(new PiiRedactionOptions { EnableTamilNer = true })));
options ?? Options.Create(new PiiRedactionOptions { EnableTamilNer = true }),
NullLogger<RoutingOnnxNerModelRunner>.Instance));
public static IPiiDetector CreateCompositeDetector(IOnnxNerModelRunner runner) =>
new CompositePiiDetector(

View File

@@ -25,7 +25,7 @@ public sealed class OnnxNerModelRunnerTests
var path = Path.Combine(Path.GetTempPath(), $"missing-ner-{Guid.NewGuid():N}.onnx");
using var runner = CreateRunner(path);
runner.PredictEntities("Customer Ravi Kumar").Should().BeEmpty();
runner.PredictEntities("Customer Ravi Kumar").Entities.Should().BeEmpty();
}
[Test]

View File

@@ -26,16 +26,13 @@ public sealed class RealNerModelRunnerTests : RealNerModelFixture
{
var entities = Runner.PredictEntities(prompt);
var result = Runner.PredictEntities(prompt);
var entities = result.Entities;
entities.Should().Contain(entity =>
entity.Type == PiiEntityType.Person &&
entity.Source == PiiDetectionSource.Ner &&
entity.ModelOrigin == NerModelOrigin.English &&
entity.Value.Contains(expectedNamePart, StringComparison.Ordinal) &&
prompt.AsSpan(entity.StartIndex, entity.Length).ToString() == entity.Value);
@@ -43,9 +40,6 @@ public sealed class RealNerModelRunnerTests : RealNerModelFixture
entities.Should().Contain(entity => entity.Value == expectedValue);
result.InvokedModels.Should().Equal(NerModelOrigin.English);
}
}

View File

@@ -21,15 +21,18 @@ public sealed class RealTamilNerModelRunnerTests : RealTamilNerModelFixture
string expectedNamePart,
string expectedValue)
{
var entities = Runner.PredictEntities(prompt);
var result = Runner.PredictEntities(prompt);
var entities = result.Entities;
entities.Should().Contain(entity =>
entity.Type == PiiEntityType.Person &&
entity.Source == PiiDetectionSource.Ner &&
entity.ModelOrigin == NerModelOrigin.Tamil &&
entity.Value.Contains(expectedNamePart, StringComparison.Ordinal) &&
prompt.AsSpan(entity.StartIndex, entity.Length).ToString() == entity.Value);
entities.Should().Contain(entity => entity.Value == expectedValue);
result.InvokedModels.Should().Equal(NerModelOrigin.Tamil);
}
[Test]
@@ -37,11 +40,12 @@ public sealed class RealTamilNerModelRunnerTests : RealTamilNerModelFixture
{
const string prompt = "வாடிக்கையாளர் ராஜேஷ் குமார் தொலைபேசி 9876543210";
var entities = Runner.PredictEntities(prompt);
var entities = Runner.PredictEntities(prompt).Entities;
entities.Should().Contain(entity =>
entity.Type == PiiEntityType.Person &&
entity.Source == PiiDetectionSource.Ner &&
entity.ModelOrigin == NerModelOrigin.Tamil &&
entity.Value.Contains("ராஜேஷ்", StringComparison.Ordinal));
entities.Should().NotContain(entity => entity.Type == PiiEntityType.Phone);
}
@@ -51,7 +55,7 @@ public sealed class RealTamilNerModelRunnerTests : RealTamilNerModelFixture
{
const string prompt = "பணத்தை திரும்பப் பெறுவது எப்படி?";
Runner.PredictEntities(prompt).Should().BeEmpty();
Runner.PredictEntities(prompt).Entities.Should().BeEmpty();
}
[TestCase("Customer Senthil phone 9876543210", "Senthil")]
@@ -61,7 +65,7 @@ public sealed class RealTamilNerModelRunnerTests : RealTamilNerModelFixture
{
// TamilOnnxNerRunner is script-scoped; Tanglish is handled by English routing in pipeline tests.
// Direct Tamil runner on Latin-only text should not emit person spans.
var entities = Runner.PredictEntities(prompt);
var entities = Runner.PredictEntities(prompt).Entities;
entities.Should().NotContain(entity =>
entity.Type == PiiEntityType.Person &&

View File

@@ -11,13 +11,15 @@ public sealed class RoutingOnnxNerModelRunnerTests
[Test]
public void PredictEntities_LatinOnly_UsesEnglishRunnerOnly()
{
var english = new FakeLanguageNerRunner("Ravi Kumar");
var tamil = new FakeLanguageNerRunner("தமிழ் பெயர்");
var english = new FakeLanguageNerRunner("Ravi Kumar", NerModelOrigin.English);
var tamil = new FakeLanguageNerRunner("தமிழ் பெயர்", NerModelOrigin.Tamil);
var router = new RoutingOnnxNerModelRunner(english, tamil, enableTamilNer: true);
var entities = router.PredictEntities("Customer Ravi Kumar called.");
var result = router.PredictEntities("Customer Ravi Kumar called.");
entities.Should().ContainSingle(entity => entity.Value == "Ravi Kumar");
result.Entities.Should().ContainSingle(entity =>
entity.Value == "Ravi Kumar" && entity.ModelOrigin == NerModelOrigin.English);
result.InvokedModels.Should().Equal(NerModelOrigin.English);
english.CallCount.Should().Be(1);
tamil.CallCount.Should().Be(0);
}
@@ -25,38 +27,46 @@ public sealed class RoutingOnnxNerModelRunnerTests
[Test]
public void PredictEntities_TamilOnly_UsesTamilRunnerOnly()
{
var english = new FakeLanguageNerRunner("Ravi Kumar");
var tamil = new FakeLanguageNerRunner("ராஜேஷ்");
var english = new FakeLanguageNerRunner("Ravi Kumar", NerModelOrigin.English);
var tamil = new FakeLanguageNerRunner("ராஜேஷ்", NerModelOrigin.Tamil);
var router = new RoutingOnnxNerModelRunner(english, tamil, enableTamilNer: true);
var entities = router.PredictEntities("வாடிக்கையாளர் ராஜேஷ்");
var result = router.PredictEntities("வாடிக்கையாளர் ராஜேஷ்");
entities.Should().ContainSingle(entity => entity.Value == "ராஜேஷ்");
result.Entities.Should().ContainSingle(entity =>
entity.Value == "ராஜேஷ்" && entity.ModelOrigin == NerModelOrigin.Tamil);
result.InvokedModels.Should().Equal(NerModelOrigin.Tamil);
english.CallCount.Should().Be(0);
tamil.CallCount.Should().Be(1);
}
[Test]
public void PredictEntities_Mixed_InvokesBothRunners()
public void PredictEntities_Mixed_InvokesBothRunnersAndTagsOrigins()
{
var english = new FakeLanguageNerRunner("EnglishName");
var tamil = new FakeLanguageNerRunner("தமிழ்");
var english = new FakeLanguageNerRunner("Priya", NerModelOrigin.English);
var tamil = new FakeLanguageNerRunner("மற்றும்", NerModelOrigin.Tamil);
var router = new RoutingOnnxNerModelRunner(english, tamil, enableTamilNer: true);
router.PredictEntities("Rajesh மற்றும் Priya");
var result = router.PredictEntities("Rajesh மற்றும் Priya");
english.CallCount.Should().Be(1);
tamil.CallCount.Should().Be(1);
result.InvokedModels.Should().Equal(NerModelOrigin.English, NerModelOrigin.Tamil);
result.Entities.Should().Contain(entity => entity.ModelOrigin == NerModelOrigin.English);
result.Entities.Should().Contain(entity => entity.ModelOrigin == NerModelOrigin.Tamil);
}
[Test]
public void PredictEntities_NoLetters_InvokesNeither()
{
var english = new FakeLanguageNerRunner("ignored");
var tamil = new FakeLanguageNerRunner("ignored");
var english = new FakeLanguageNerRunner("ignored", NerModelOrigin.English);
var tamil = new FakeLanguageNerRunner("ignored", NerModelOrigin.Tamil);
var router = new RoutingOnnxNerModelRunner(english, tamil, enableTamilNer: true);
router.PredictEntities("9876543210").Should().BeEmpty();
var result = router.PredictEntities("9876543210");
result.Entities.Should().BeEmpty();
result.InvokedModels.Should().BeEmpty();
english.CallCount.Should().Be(0);
tamil.CallCount.Should().Be(0);
}
@@ -64,14 +74,15 @@ public sealed class RoutingOnnxNerModelRunnerTests
[Test]
public void PredictEntities_TamilDisabled_SkipsTamilRunnerForMixedText()
{
var english = new FakeLanguageNerRunner("EnglishName");
var tamil = new FakeLanguageNerRunner("தமிழ்");
var english = new FakeLanguageNerRunner("EnglishName", NerModelOrigin.English);
var tamil = new FakeLanguageNerRunner("தமிழ்", NerModelOrigin.Tamil);
var router = new RoutingOnnxNerModelRunner(english, tamil, enableTamilNer: false);
router.PredictEntities("Rajesh மற்றும் Priya");
var result = router.PredictEntities("Rajesh மற்றும் Priya");
english.CallCount.Should().Be(1);
tamil.CallCount.Should().Be(0);
result.InvokedModels.Should().Equal(NerModelOrigin.English);
}
[Test]
@@ -79,40 +90,54 @@ public sealed class RoutingOnnxNerModelRunnerTests
{
var entities = new[]
{
CreatePerson("Raj", 0, 3),
CreatePerson("Rajesh", 0, 6)
CreatePerson("Raj", 0, 3, NerModelOrigin.English),
CreatePerson("Rajesh", 0, 6, NerModelOrigin.Tamil)
};
var merged = RoutingOnnxNerModelRunner.MergePersonSpans(entities);
merged.Should().ContainSingle(entity => entity.Value == "Rajesh");
merged.Should().ContainSingle(entity =>
entity.Value == "Rajesh" && entity.ModelOrigin == NerModelOrigin.Tamil);
}
private static PiiEntity CreatePerson(string value, int start, int length) =>
new(PiiEntityType.Person, value, start, length, PiiDetectionSource.Ner);
private static PiiEntity CreatePerson(string value, int start, int length, NerModelOrigin origin) =>
new(PiiEntityType.Person, value, start, length, PiiDetectionSource.Ner, ModelOrigin: origin);
private sealed class FakeLanguageNerRunner : IOnnxNerModelRunner
{
private readonly string _personValue;
private readonly NerModelOrigin _origin;
public FakeLanguageNerRunner(string personValue) => _personValue = personValue;
public FakeLanguageNerRunner(string personValue, NerModelOrigin origin)
{
_personValue = personValue;
_origin = origin;
}
public int CallCount { get; private set; }
public bool IsModelAvailable => true;
public IReadOnlyList<PiiEntity> PredictEntities(string text)
public NerPredictionResult PredictEntities(string text)
{
CallCount++;
return
var start = text.IndexOf(_personValue, StringComparison.Ordinal);
if (start < 0)
{
start = 0;
}
return new NerPredictionResult(
[
new PiiEntity(
PiiEntityType.Person,
_personValue,
0,
start,
_personValue.Length,
PiiDetectionSource.Ner)
];
PiiDetectionSource.Ner,
ModelOrigin: _origin)
],
[_origin]);
}
}
}

View File

@@ -57,7 +57,7 @@ public abstract class RealRoutingNerModelFixture
Assert.Ignore(RealTamilNerModelPaths.ModelMissingMessage);
}
Runner = new RoutingOnnxNerModelRunner(EnglishRunner, TamilRunner, options);
Runner = new RoutingOnnxNerModelRunner(EnglishRunner, TamilRunner, options, NullLogger<RoutingOnnxNerModelRunner>.Instance);
}
[OneTimeTearDown]