< Summary

Information
Class: Intentum.Analytics.IntentAnalytics
Assembly: Intentum.Analytics
File(s): /home/runner/work/Intentum/Intentum/src/Intentum.Analytics/IntentAnalytics.cs
Line coverage
79%
Covered lines: 176
Uncovered lines: 46
Coverable lines: 222
Total lines: 322
Line coverage: 79.2%
Branch coverage
61%
Covered branches: 38
Total branches: 62
Branch coverage: 61.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Cyclomatic complexity NPath complexity Sequence coverage
.cctor()100%11100%
.ctor(...)50%22100%
GetConfidenceTrendsAsync()91.66%121296.42%
GetDecisionDistributionAsync()100%44100%
DetectAnomaliesAsync()55%202042.37%
ComputeMeanStdDev(...)50%22100%
ComputeQuartiles(...)50%2237.5%
GetVolumeAnomalyMethod(...)0%660%
GetSummaryAsync()100%11100%
GetIntentTimelineAsync()100%11100%
GetIntentGraphSnapshotAsync()100%22100%
ExportToJsonAsync()100%11100%
ExportToCsvAsync()100%22100%
TruncateToBucket(...)100%11100%
EscapeCsv(...)50%8883.33%

File(s)

/home/runner/work/Intentum/Intentum/src/Intentum.Analytics/IntentAnalytics.cs

#LineLine coverage
 1using System.Text;
 2using System.Text.Json;
 3using Intentum.Analytics.Models;
 4using Intentum.Persistence.Repositories;
 5using Intentum.Runtime.Policy;
 6
 7namespace Intentum.Analytics;
 8
 9/// <summary>
 10/// Default implementation of intent analytics using intent history repository.
 11/// </summary>
 12public sealed class IntentAnalytics : IIntentAnalytics
 13{
 114    private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
 15
 16    private readonly IIntentHistoryRepository _historyRepository;
 17
 918    public IntentAnalytics(IIntentHistoryRepository historyRepository)
 919    {
 920        _historyRepository = historyRepository ?? throw new ArgumentNullException(nameof(historyRepository));
 921    }
 22
 23    /// <inheritdoc />
 24    public async Task<IReadOnlyList<ConfidenceTrendPoint>> GetConfidenceTrendsAsync(
 25        DateTimeOffset start,
 26        DateTimeOffset end,
 27        TimeSpan bucketSize,
 28        CancellationToken cancellationToken = default)
 229    {
 230        var records = await _historyRepository.GetByTimeWindowAsync(start, end, cancellationToken);
 231        var buckets = new Dictionary<(DateTimeOffset Bucket, string Level), (int Count, double Sum)>();
 32
 2633        foreach (var r in records)
 1034        {
 1035            var bucketStart = TruncateToBucket(r.RecordedAt, bucketSize);
 1036            var key = (bucketStart, r.ConfidenceLevel);
 1037            if (!buckets.TryGetValue(key, out var existing))
 838                existing = (0, 0);
 1039            buckets[key] = (existing.Count + 1, existing.Sum + r.ConfidenceScore);
 1040        }
 41
 1042        var bucketEnds = buckets.Keys.Select(k => k.Bucket).Distinct().OrderBy(x => x).ToList();
 1043        var levels = buckets.Keys.Select(k => k.Level).Distinct().ToList();
 244        var result = new List<ConfidenceTrendPoint>();
 45
 1046        foreach (var bucketStart in bucketEnds)
 247        {
 248            var bucketEnd = bucketStart + bucketSize;
 2249            foreach (var level in levels)
 850            {
 851                if (!buckets.TryGetValue((bucketStart, level), out var v))
 052                    continue;
 853                result.Add(new ConfidenceTrendPoint(
 854                    bucketStart,
 855                    bucketEnd,
 856                    level,
 857                    v.Count,
 858                    v.Count > 0 ? v.Sum / v.Count : 0));
 859            }
 260        }
 61
 1862        return result.OrderBy(x => x.BucketStart).ThenBy(x => x.ConfidenceLevel).ToList();
 263    }
 64
 65    /// <inheritdoc />
 66    public async Task<DecisionDistributionReport> GetDecisionDistributionAsync(
 67        DateTimeOffset start,
 68        DateTimeOffset end,
 69        CancellationToken cancellationToken = default)
 270    {
 271        var records = await _historyRepository.GetByTimeWindowAsync(start, end, cancellationToken);
 272        var countByDecision = records
 1073            .GroupBy(r => r.Decision)
 1874            .ToDictionary(g => g.Key, g => g.Count());
 75
 3276        foreach (var decision in Enum.GetValues<PolicyDecision>().Where(d => !countByDecision.ContainsKey(d)))
 677            countByDecision[decision] = 0;
 78
 279        return new DecisionDistributionReport(
 280            start,
 281            end,
 282            records.Count,
 283            countByDecision);
 284    }
 85
 86    /// <inheritdoc />
 87    public async Task<IReadOnlyList<AnomalyReport>> DetectAnomaliesAsync(
 88        DateTimeOffset start,
 89        DateTimeOffset end,
 90        TimeSpan bucketSize,
 91        CancellationToken cancellationToken = default)
 192    {
 193        var records = await _historyRepository.GetByTimeWindowAsync(start, end, cancellationToken);
 194        var anomalies = new List<AnomalyReport>();
 95
 196        if (!records.Any())
 097            return anomalies;
 98
 699        var buckets = records.GroupBy(r => TruncateToBucket(r.RecordedAt, bucketSize)).ToList();
 2100        var bucketCounts = buckets.Select(g => (double)g.Count()).ToList();
 101
 1102        var (mean, stdDev) = ComputeMeanStdDev(bucketCounts);
 1103        var (q1, q3) = ComputeQuartiles(bucketCounts);
 1104        var iqr = q3 - q1;
 1105        var iqrUpperFence = q3 + 1.5 * iqr;
 106
 1107        var blockRates = buckets.Select(g =>
 7108            g.Any() ? g.Count(r => r.Decision == PolicyDecision.Block) / (double)g.Count() : 0.0).ToList();
 1109        var (blockMean, blockStdDev) = ComputeMeanStdDev(blockRates);
 110
 6111        foreach (var (g, _) in buckets.Select((g, i) => (g, i)))
 1112        {
 1113            var bucketStart = g.Key;
 1114            var bucketEnd = bucketStart + bucketSize;
 1115            var bucketRecords = g.ToList();
 1116            var count = (double)bucketRecords.Count;
 117
 1118            var volumeZScore = stdDev > 0 ? (count - mean) / stdDev : 0;
 1119            if (volumeZScore > 2.0 || count > iqrUpperFence)
 0120            {
 0121                var method = GetVolumeAnomalyMethod(volumeZScore, count, iqrUpperFence);
 0122                anomalies.Add(new AnomalyReport(
 0123                    "VolumeSpike",
 0124                    $"Volume spike: {bucketRecords.Count} inferences (mean: {mean:F0}, Z: {volumeZScore:F2}, IQR fence: 
 0125                    bucketStart, bucketStart, bucketEnd,
 0126                    Math.Clamp(volumeZScore / 4, 0, 1),
 0127                    new Dictionary<string, object>
 0128                    {
 0129                        ["Count"] = bucketRecords.Count, ["Mean"] = mean,
 0130                        ["StdDev"] = stdDev, ["ZScore"] = volumeZScore,
 0131                        ["IqrUpperFence"] = iqrUpperFence, ["Method"] = method
 0132                    }));
 0133            }
 134
 1135            var blockRate = bucketRecords.Count > 0
 5136                ? bucketRecords.Count(r => r.Decision == PolicyDecision.Block) / (double)bucketRecords.Count
 1137                : 0;
 1138            var blockZScore = blockStdDev > 0 ? (blockRate - blockMean) / blockStdDev : 0;
 1139            if (blockZScore > 2.0)
 0140            {
 0141                anomalies.Add(new AnomalyReport(
 0142                    "BlockRateSpike",
 0143                    $"Block rate spike: {blockRate:P0} (mean: {blockMean:P0}, Z: {blockZScore:F2})",
 0144                    bucketStart, bucketStart, bucketEnd,
 0145                    Math.Clamp(blockZScore / 4, 0, 1),
 0146                    new Dictionary<string, object>
 0147                    {
 0148                        ["BlockRate"] = blockRate, ["Mean"] = blockMean,
 0149                        ["StdDev"] = blockStdDev, ["ZScore"] = blockZScore
 0150                    }));
 0151            }
 152
 1153            var lowConfidenceCount = bucketRecords.Count(r =>
 6154                string.Equals(r.ConfidenceLevel, "Low", StringComparison.OrdinalIgnoreCase));
 1155            if (bucketRecords.Count >= 2 && lowConfidenceCount / (double)bucketRecords.Count >= 0.5)
 0156            {
 0157                anomalies.Add(new AnomalyReport(
 0158                    "LowConfidenceCluster",
 0159                    $"Low confidence cluster: {lowConfidenceCount}/{bucketRecords.Count} in bucket",
 0160                    bucketStart, bucketStart, bucketEnd, 0.5,
 0161                    new Dictionary<string, object> { ["LowCount"] = lowConfidenceCount, ["TotalInBucket"] = bucketRecord
 0162            }
 1163        }
 164
 1165        return anomalies.OrderByDescending(a => a.Severity).ToList();
 1166    }
 167
 168    private static (double Mean, double StdDev) ComputeMeanStdDev(List<double> values)
 2169    {
 2170        if (values.Count == 0) return (0, 0);
 2171        var mean = values.Average();
 4172        var variance = values.Sum(v => (v - mean) * (v - mean)) / values.Count;
 2173        return (mean, Math.Sqrt(variance));
 2174    }
 175
 176    private static (double Q1, double Q3) ComputeQuartiles(List<double> values)
 1177    {
 2178        if (values.Count < 4) return (values.Min(), values.Max());
 0179        var sorted = values.OrderBy(v => v).ToList();
 0180        var n = sorted.Count;
 0181        var q1 = sorted[(int)(n * 0.25)];
 0182        var q3 = sorted[(int)(n * 0.75)];
 0183        return (q1, q3);
 1184    }
 185
 186    private static string GetVolumeAnomalyMethod(double volumeZScore, double count, double iqrUpperFence)
 0187    {
 0188        if (volumeZScore > 2.0 && count > iqrUpperFence) return "Z-score + IQR";
 0189        if (volumeZScore > 2.0) return "Z-score";
 0190        return "IQR";
 0191    }
 192
 193    /// <inheritdoc />
 194    public async Task<AnalyticsSummary> GetSummaryAsync(
 195        DateTimeOffset start,
 196        DateTimeOffset end,
 197        TimeSpan bucketSize,
 198        CancellationToken cancellationToken = default)
 1199    {
 1200        var trends = await GetConfidenceTrendsAsync(start, end, bucketSize, cancellationToken);
 1201        var distribution = await GetDecisionDistributionAsync(start, end, cancellationToken);
 1202        var anomalies = await DetectAnomaliesAsync(start, end, bucketSize, cancellationToken);
 203
 1204        var records = await _historyRepository.GetByTimeWindowAsync(start, end, cancellationToken);
 6205        var uniqueSpaces = records.Select(r => r.BehaviorSpaceId).Distinct().Count();
 206
 1207        return new AnalyticsSummary(
 1208            start,
 1209            end,
 1210            records.Count,
 1211            uniqueSpaces,
 1212            trends,
 1213            distribution,
 1214            anomalies);
 1215    }
 216
 217    /// <inheritdoc />
 218    public async Task<IntentTimeline> GetIntentTimelineAsync(
 219        string entityId,
 220        DateTimeOffset start,
 221        DateTimeOffset end,
 222        CancellationToken cancellationToken = default)
 3223    {
 3224        var records = await _historyRepository.GetByEntityIdAsync(entityId, start, end, cancellationToken);
 3225        var points = records
 4226            .Select(r => new IntentTimelinePoint(
 4227                r.RecordedAt,
 4228                r.IntentName,
 4229                r.ConfidenceLevel,
 4230                r.ConfidenceScore,
 4231                r.Decision))
 3232            .ToList();
 3233        return new IntentTimeline(entityId, start, end, points);
 3234    }
 235
 236    /// <inheritdoc />
 237    public async Task<IntentGraphSnapshot> GetIntentGraphSnapshotAsync(
 238        string entityId,
 239        DateTimeOffset start,
 240        DateTimeOffset end,
 241        CancellationToken cancellationToken = default)
 1242    {
 1243        var records = (await _historyRepository.GetByEntityIdAsync(entityId, start, end, cancellationToken))
 2244            .OrderBy(r => r.RecordedAt)
 1245            .ToList();
 246
 1247        var nodes = records
 2248            .Select(r => new IntentGraphNode(
 2249                r.Id,
 2250                r.IntentName,
 2251                r.ConfidenceScore,
 2252                r.ConfidenceLevel,
 2253                r.RecordedAt))
 1254            .ToList();
 255
 1256        var edges = new List<IntentGraphEdge>();
 4257        for (var i = 0; i < records.Count - 1; i++)
 1258        {
 1259            var from = records[i];
 1260            var to = records[i + 1];
 1261            edges.Add(new IntentGraphEdge(from.Id, to.Id, to.RecordedAt));
 1262        }
 263
 1264        return new IntentGraphSnapshot(
 1265            entityId,
 1266            start,
 1267            end,
 1268            nodes,
 1269            edges,
 1270            DateTimeOffset.UtcNow);
 1271    }
 272
 273    /// <inheritdoc />
 274    public async Task<string> ExportToJsonAsync(
 275        DateTimeOffset start,
 276        DateTimeOffset end,
 277        CancellationToken cancellationToken = default)
 1278    {
 1279        var records = await _historyRepository.GetByTimeWindowAsync(start, end, cancellationToken);
 6280        var dto = records.Select(r => new
 6281        {
 6282            r.Id,
 6283            r.BehaviorSpaceId,
 6284            r.IntentName,
 6285            r.ConfidenceLevel,
 6286            r.ConfidenceScore,
 6287            Decision = r.Decision.ToString(),
 6288            r.RecordedAt
 6289        }).ToList();
 1290        return JsonSerializer.Serialize(dto, JsonOptions);
 1291    }
 292
 293    /// <inheritdoc />
 294    public async Task<string> ExportToCsvAsync(
 295        DateTimeOffset start,
 296        DateTimeOffset end,
 297        CancellationToken cancellationToken = default)
 1298    {
 1299        var records = await _historyRepository.GetByTimeWindowAsync(start, end, cancellationToken);
 1300        var sb = new StringBuilder();
 1301        sb.AppendLine("Id,BehaviorSpaceId,IntentName,ConfidenceLevel,ConfidenceScore,Decision,RecordedAt");
 13302        foreach (var r in records)
 5303        {
 5304            sb.AppendLine($"{EscapeCsv(r.Id)},{EscapeCsv(r.BehaviorSpaceId)},{EscapeCsv(r.IntentName)},{EscapeCsv(r.Conf
 5305        }
 1306        return sb.ToString();
 1307    }
 308
 309    private static DateTimeOffset TruncateToBucket(DateTimeOffset value, TimeSpan bucketSize)
 15310    {
 15311        var ticks = value.UtcTicks - (value.UtcTicks % bucketSize.Ticks);
 15312        return new DateTimeOffset(ticks, TimeSpan.Zero);
 15313    }
 314
 315    private static string EscapeCsv(string? value)
 25316    {
 25317        if (string.IsNullOrEmpty(value)) return "";
 25318        if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
 0319            return "\"" + value.Replace("\"", "\"\"") + "\"";
 25320        return value;
 25321    }
 322}