using Microsoft.Extensions.Logging; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using PiiRedaction.Core.Models; namespace PiiRedaction.Infrastructure.Onnx; /// /// Shared ONNX token-classification inference and BIO decoding for NER models. /// public sealed class OnnxTokenClassifierRunner : IDisposable { private const int MaxSequenceLength = 128; private readonly ITokenClassifierEncoder _encoder; private readonly NerLabelConfig _labelConfig; private readonly string[] _labels; private readonly ILogger _logger; private readonly string _modelPath; private InferenceSession? _session; public OnnxTokenClassifierRunner( string modelPath, ITokenClassifierEncoder encoder, NerLabelConfig labelConfig, string[] labels, ILogger logger) { _modelPath = modelPath; _encoder = encoder; _labelConfig = labelConfig; _labels = labels; _logger = logger; _session = TryCreateSession(); } public bool IsAvailable => _session is not null && _encoder.IsAvailable && _labels.Length > 0; public IReadOnlyList PredictEntities(string text) { if (!IsAvailable || _session is null) { return []; } var encoded = _encoder.Encode(text, MaxSequenceLength); if (encoded is null) { return []; } var predictedLabelIds = RunInference(encoded); return DecodePersonEntities(text, predictedLabelIds, encoded); } private int[] RunInference(EncodedSequence encoded) { var inputIdsTensor = CreateTensor(encoded.InputIds, encoded.SequenceLength); var attentionMaskTensor = CreateTensor(encoded.AttentionMask, encoded.SequenceLength); var inputs = new List { NamedOnnxValue.CreateFromTensor( _session!.InputMetadata.Keys.First(key => key.Contains("input_ids", StringComparison.OrdinalIgnoreCase)), inputIdsTensor), NamedOnnxValue.CreateFromTensor( _session.InputMetadata.Keys.First(key => key.Contains("attention_mask", StringComparison.OrdinalIgnoreCase)), attentionMaskTensor) }; var tokenTypeInputName = _session.InputMetadata.Keys.FirstOrDefault(key => key.Contains("token_type", StringComparison.OrdinalIgnoreCase)); if (tokenTypeInputName is not null) { inputs.Add(NamedOnnxValue.CreateFromTensor( tokenTypeInputName, CreateTensor(encoded.TokenTypeIds, encoded.SequenceLength))); } using var results = _session.Run(inputs); var outputName = _session.OutputMetadata.Keys.FirstOrDefault(key => key.Contains("logits", StringComparison.OrdinalIgnoreCase)) ?? results.First().Name; var logits = results.First(result => result.Name == outputName).AsTensor(); var numLabels = _labels.Length; var predictions = new int[encoded.SequenceLength]; for (var tokenIndex = 0; tokenIndex < encoded.SequenceLength; tokenIndex++) { var bestLabel = 0; var bestScore = float.MinValue; for (var labelIndex = 0; labelIndex < numLabels; labelIndex++) { var score = logits[0, tokenIndex, labelIndex]; if (score > bestScore) { bestScore = score; bestLabel = labelIndex; } } predictions[tokenIndex] = bestLabel; } return predictions; } private static DenseTensor CreateTensor(long[] values, int sequenceLength) { var tensor = new DenseTensor([1, sequenceLength]); for (var i = 0; i < sequenceLength; i++) { tensor[0, i] = values[i]; } return tensor; } private IReadOnlyList DecodePersonEntities( string text, int[] predictedLabelIds, EncodedSequence encoded) { var entities = new List(); int? entityStart = null; int? entityEnd = null; void FlushEntity() { if (!entityStart.HasValue || !entityEnd.HasValue || entityEnd.Value <= entityStart.Value) { entityStart = null; entityEnd = null; return; } var value = text[entityStart.Value..entityEnd.Value]; if (!string.IsNullOrWhiteSpace(value)) { entities.Add(new PiiEntity( PiiEntityType.Person, value, entityStart.Value, entityEnd.Value - entityStart.Value, PiiDetectionSource.Ner)); } entityStart = null; entityEnd = null; } for (var i = 0; i < encoded.SequenceLength; i++) { if (_encoder.IsSpecialToken(encoded.TokenIds[i])) { FlushEntity(); continue; } var label = _labels[predictedLabelIds[i]]; var (start, end) = encoded.Offsets[i]; var hasOffset = end > start; if (!_labelConfig.IsPersonLabel(label)) { FlushEntity(); continue; } if (_labelConfig.IsBeginLabel(label)) { FlushEntity(); if (hasOffset) { entityStart = start; entityEnd = end; } } else if (_labelConfig.IsInsideLabel(label)) { if (!entityStart.HasValue && hasOffset) { entityStart = start; entityEnd = end; } else if (hasOffset) { entityEnd = Math.Max(entityEnd ?? end, end); } } } FlushEntity(); return entities; } private InferenceSession? TryCreateSession() { if (!File.Exists(_modelPath)) { _logger.LogWarning( "ONNX NER model not found at {ModelPath}. Person-name detection will return no results.", _modelPath); return null; } if (!_encoder.IsAvailable || _labels.Length == 0) { _logger.LogWarning( "Tokenizer or label map missing for ONNX NER model at {ModelPath}.", _modelPath); return null; } try { var session = new InferenceSession(_modelPath); _logger.LogInformation("ONNX NER model loaded from {ModelPath}.", _modelPath); return session; } catch (Exception ex) { _logger.LogError(ex, "Failed to load ONNX NER model from {ModelPath}.", _modelPath); return null; } } public void Dispose() => _session?.Dispose(); }