| | | 1 | | using Intentum.Core.Behavior; |
| | | 2 | | using Intentum.Core.Contracts; |
| | | 3 | | using Intentum.Core.Intents; |
| | | 4 | | using Microsoft.ML.OnnxRuntime; |
| | | 5 | | |
| | | 6 | | namespace Intentum.AI.ONNX; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// Local intent classifier using ONNX Runtime. Implements <see cref="IIntentModel"/> for offline or low-latency inferen |
| | | 10 | | /// Expects model input: float tensor [1, N]; output: float tensor [1, C] (logits). Maps argmax to intent name and softm |
| | | 11 | | /// </summary> |
| | | 12 | | public sealed class OnnxIntentModel : IIntentModel, IDisposable |
| | | 13 | | { |
| | | 14 | | private readonly InferenceSession _session; |
| | | 15 | | private readonly OnnxIntentModelOptions _options; |
| | | 16 | | private readonly string _inputName; |
| | | 17 | | private readonly string _outputName; |
| | | 18 | | private readonly int _inputSize; |
| | | 19 | | |
| | | 20 | | /// <summary> |
| | | 21 | | /// Creates an ONNX intent model from the given options. Loads the model file and validates input/output shapes. |
| | | 22 | | /// </summary> |
| | 7 | 23 | | public OnnxIntentModel(OnnxIntentModelOptions options) |
| | 7 | 24 | | { |
| | 7 | 25 | | _options = options ?? throw new ArgumentNullException(nameof(options)); |
| | 6 | 26 | | if (string.IsNullOrWhiteSpace(options.ModelPath) || !File.Exists(options.ModelPath)) |
| | 1 | 27 | | throw new ArgumentException($"Model file not found: {options.ModelPath}", nameof(options)); |
| | 5 | 28 | | if (options.IntentLabels is not { Count: > 0 }) |
| | 1 | 29 | | throw new ArgumentException("IntentLabels must be non-empty.", nameof(options)); |
| | | 30 | | |
| | 4 | 31 | | _session = new InferenceSession(options.ModelPath); |
| | | 32 | | |
| | 4 | 33 | | _inputName = options.InputName ?? _session.InputNames[0]; |
| | 4 | 34 | | _outputName = options.OutputName ?? _session.OutputNames[0]; |
| | | 35 | | |
| | 4 | 36 | | var inputMeta = _session.InputMetadata[_inputName]; |
| | 4 | 37 | | var outputMeta = _session.OutputMetadata[_outputName]; |
| | | 38 | | |
| | 4 | 39 | | _inputSize = Convert.ToInt32(inputMeta.Dimensions[^1]); |
| | 4 | 40 | | var numClasses = Convert.ToInt32(outputMeta.Dimensions[^1]); |
| | | 41 | | |
| | 4 | 42 | | if (options.IntentLabels.Count != numClasses) |
| | 1 | 43 | | throw new ArgumentException( |
| | 1 | 44 | | $"IntentLabels count ({options.IntentLabels.Count}) must match model output size ({numClasses}).", |
| | 1 | 45 | | nameof(options)); |
| | | 46 | | |
| | 3 | 47 | | if (options.FeatureDimensionNames != null && options.FeatureDimensionNames.Count != _inputSize) |
| | 1 | 48 | | throw new ArgumentException( |
| | 1 | 49 | | $"FeatureDimensionNames count ({options.FeatureDimensionNames.Count}) must match model input size ({_inp |
| | 1 | 50 | | nameof(options)); |
| | 2 | 51 | | } |
| | | 52 | | |
| | | 53 | | /// <inheritdoc /> |
| | | 54 | | public Intent Infer(BehaviorSpace behaviorSpace, BehaviorVector? precomputedVector = null) |
| | 2 | 55 | | { |
| | 2 | 56 | | var vector = precomputedVector ?? behaviorSpace.ToVector(); |
| | 2 | 57 | | var inputFloats = VectorToFloats(vector); |
| | | 58 | | |
| | 2 | 59 | | using var inputOrt = OrtValue.CreateTensorValueFromMemory(inputFloats, [1L, _inputSize]); |
| | 2 | 60 | | var inputs = new Dictionary<string, OrtValue> { { _inputName, inputOrt } }; |
| | 2 | 61 | | var outputNames = new[] { _outputName }; |
| | | 62 | | |
| | 2 | 63 | | using var runOptions = new RunOptions(); |
| | 2 | 64 | | using var outputs = _session.Run(runOptions, inputs, outputNames); |
| | 2 | 65 | | if (outputs.Count == 0) |
| | 0 | 66 | | return new Intent("Unknown", [], new IntentConfidence(0, "None"), "ONNX output missing."); |
| | | 67 | | |
| | 2 | 68 | | var (intentIndex, confidence) = LogitsToIntent(outputs[0]); |
| | | 69 | | |
| | 2 | 70 | | var intentName = intentIndex >= 0 && intentIndex < _options.IntentLabels.Count |
| | 2 | 71 | | ? _options.IntentLabels[intentIndex] |
| | 2 | 72 | | : "Unknown"; |
| | | 73 | | |
| | 2 | 74 | | return new Intent( |
| | 2 | 75 | | intentName, |
| | 2 | 76 | | [], |
| | 2 | 77 | | new IntentConfidence(confidence, ConfidenceLevel(confidence)), |
| | 2 | 78 | | $"ONNX index {intentIndex}"); |
| | 2 | 79 | | } |
| | | 80 | | |
| | | 81 | | /// <inheritdoc /> |
| | 2 | 82 | | public void Dispose() => _session.Dispose(); |
| | | 83 | | |
| | | 84 | | private float[] VectorToFloats(BehaviorVector vector) |
| | 2 | 85 | | { |
| | 2 | 86 | | var dims = vector.Dimensions; |
| | 2 | 87 | | var values = new float[_inputSize]; |
| | | 88 | | |
| | 2 | 89 | | if (_options.FeatureDimensionNames != null) |
| | 0 | 90 | | { |
| | 0 | 91 | | var names = _options.FeatureDimensionNames; |
| | 0 | 92 | | for (var i = 0; i < names.Count; i++) |
| | 0 | 93 | | values[i] = Convert.ToSingle(dims.GetValueOrDefault(names[i], 0.0)); |
| | 0 | 94 | | } |
| | | 95 | | else |
| | 2 | 96 | | { |
| | 4 | 97 | | var keys = dims.Keys.OrderBy(k => k, StringComparer.Ordinal).ToList(); |
| | 10 | 98 | | for (var i = 0; i < Math.Min(keys.Count, _inputSize); i++) |
| | 3 | 99 | | values[i] = Convert.ToSingle(dims[keys[i]]); |
| | 2 | 100 | | } |
| | | 101 | | |
| | 2 | 102 | | return values; |
| | 2 | 103 | | } |
| | | 104 | | |
| | | 105 | | private static (int IntentIndex, double Confidence) LogitsToIntent(OrtValue outputOrt) |
| | 3 | 106 | | { |
| | 3 | 107 | | var span = outputOrt.GetTensorDataAsSpan<float>(); |
| | 3 | 108 | | if (span.Length == 0) |
| | 0 | 109 | | return (-1, 0); |
| | | 110 | | |
| | 3 | 111 | | var logits = new float[span.Length]; |
| | 3 | 112 | | span.CopyTo(logits); |
| | | 113 | | |
| | 3 | 114 | | var maxIdx = 0; |
| | 3 | 115 | | var maxLogit = logits[0]; |
| | 18 | 116 | | for (var i = 1; i < logits.Length; i++) |
| | 6 | 117 | | { |
| | 6 | 118 | | if (logits[i] > maxLogit) |
| | 1 | 119 | | { |
| | 1 | 120 | | maxLogit = logits[i]; |
| | 1 | 121 | | maxIdx = i; |
| | 1 | 122 | | } |
| | 6 | 123 | | } |
| | | 124 | | |
| | 3 | 125 | | var expSum = 0.0; |
| | 24 | 126 | | for (var i = 0; i < logits.Length; i++) |
| | 9 | 127 | | expSum += Math.Exp(logits[i] - maxLogit); |
| | 3 | 128 | | var confidence = expSum > 0 ? 1.0 / expSum : 0; |
| | | 129 | | |
| | 3 | 130 | | return (maxIdx, Math.Clamp(confidence, 0, 1)); |
| | 3 | 131 | | } |
| | | 132 | | |
| | | 133 | | private static string ConfidenceLevel(double score) |
| | 10 | 134 | | { |
| | 12 | 135 | | if (score >= 0.8) return "High"; |
| | 11 | 136 | | if (score >= 0.5) return "Medium"; |
| | 8 | 137 | | if (score >= 0.2) return "Low"; |
| | 2 | 138 | | return "None"; |
| | 10 | 139 | | } |
| | | 140 | | } |