< Summary

Information
Class: Intentum.Analytics.BehaviorPatternDetector
Assembly: Intentum.Analytics
File(s): /home/runner/work/Intentum/Intentum/src/Intentum.Analytics/BehaviorPatternDetector.cs
Line coverage
100%
Covered lines: 93
Uncovered lines: 0
Coverable lines: 93
Total lines: 136
Line coverage: 100%
Branch coverage
96%
Covered branches: 29
Total branches: 30
Branch coverage: 96.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Cyclomatic complexity NPath complexity Sequence coverage
.ctor(...)100%22100%
GetBehaviorPatternsAsync()100%44100%
GetPatternAnomaliesAsync()100%1010100%
MatchTemplates(...)92.85%1414100%
TruncateToBucket(...)100%11100%

File(s)

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

#LineLine coverage
 1using Intentum.Analytics.Models;
 2using Intentum.Persistence.Repositories;
 3
 4namespace Intentum.Analytics;
 5
 6/// <summary>
 7/// Detects behavior patterns and frequency anomalies from intent history using intent-name sequences.
 8/// </summary>
 9public sealed class BehaviorPatternDetector : IBehaviorPatternDetector
 10{
 11    private readonly IIntentHistoryRepository _historyRepository;
 12
 813    public BehaviorPatternDetector(IIntentHistoryRepository historyRepository)
 814    {
 815        _historyRepository = historyRepository ?? throw new ArgumentNullException(nameof(historyRepository));
 716    }
 17
 18    /// <inheritdoc />
 19    public async Task<IReadOnlyList<BehaviorPattern>> GetBehaviorPatternsAsync(
 20        DateTimeOffset start,
 21        DateTimeOffset end,
 22        int minSequenceLength = 2,
 23        int maxSequenceLength = 5,
 24        CancellationToken cancellationToken = default)
 225    {
 226        var records = await _historyRepository.GetByTimeWindowAsync(start, end, cancellationToken);
 627        var ordered = records.OrderBy(r => r.RecordedAt).ToList();
 228        var sequenceCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
 29
 1430        for (var len = Math.Min(minSequenceLength, maxSequenceLength); len <= maxSequenceLength; len++)
 531        {
 1632            for (var i = 0; i <= ordered.Count - len; i++)
 333            {
 934                var seq = ordered.Skip(i).Take(len).Select(r => r.IntentName).ToList();
 335                var key = string.Join("|", seq);
 336                sequenceCounts[key] = sequenceCounts.GetValueOrDefault(key) + 1;
 337            }
 538        }
 39
 240        return sequenceCounts
 241            .Where(kv => kv.Value >= 1)
 242            .Select(kv => new BehaviorPattern(
 243                kv.Key.Split('|').ToList(),
 244                kv.Value))
 245            .OrderByDescending(p => p.Count)
 246            .ToList();
 247    }
 48
 49    /// <inheritdoc />
 50    public async Task<IReadOnlyList<PatternAnomalyReport>> GetPatternAnomaliesAsync(
 51        DateTimeOffset start,
 52        DateTimeOffset end,
 53        TimeSpan bucketSize,
 54        double frequencyMultiplierThreshold = 10.0,
 55        CancellationToken cancellationToken = default)
 256    {
 257        var records = await _historyRepository.GetByTimeWindowAsync(start, end, cancellationToken);
 258        var reports = new List<PatternAnomalyReport>();
 59
 260        if (records.Count == 0)
 161            return reports;
 62
 2763        var globalIntentCounts = records.GroupBy(r => r.IntentName).ToDictionary(g => g.Key, g => g.Count());
 164        var total = records.Count;
 2365        var buckets = records.GroupBy(r => TruncateToBucket(r.RecordedAt, bucketSize)).ToList();
 66
 767        foreach (var bucket in buckets)
 268        {
 269            var bucketStart = bucket.Key;
 270            var bucketEnd = bucketStart + bucketSize;
 271            var bucketRecords = bucket.ToList();
 272            var bucketTotal = bucketRecords.Count;
 2873            var bucketIntentCounts = bucketRecords.GroupBy(r => r.IntentName).ToDictionary(g => g.Key, g => g.Count());
 74
 1075            foreach (var (intentName, bucketCount) in bucketIntentCounts)
 276            {
 277                var globalCount = globalIntentCounts.GetValueOrDefault(intentName, 0);
 278                var expectedInBucket = (globalCount / (double)total) * bucketTotal;
 279                if (expectedInBucket <= 0) continue;
 280                var ratio = bucketCount / expectedInBucket;
 281                if (ratio >= frequencyMultiplierThreshold)
 182                {
 183                    reports.Add(new PatternAnomalyReport(
 184                        "FrequencySpike",
 185                        $"Intent '{intentName}' {ratio:F1}x more frequent in bucket than baseline.",
 186                        bucketStart,
 187                        bucketStart,
 188                        bucketEnd,
 189                        Math.Min(1, ratio / 20),
 190                        new Dictionary<string, object>
 191                        {
 192                            ["IntentName"] = intentName,
 193                            ["BucketCount"] = bucketCount,
 194                            ["ExpectedInBucket"] = expectedInBucket,
 195                            ["Ratio"] = ratio
 196                        }));
 197                }
 298            }
 299        }
 100
 1101        return reports.OrderByDescending(r => r.Severity).ToList();
 2102    }
 103
 104    /// <inheritdoc />
 105    public IReadOnlyList<TemplateMatch> MatchTemplates(
 106        IReadOnlyList<BehaviorPattern> patterns,
 107        IReadOnlyList<IntentTemplate> templates)
 3108    {
 3109        var matches = new List<TemplateMatch>();
 13110        foreach (var template in templates)
 2111        {
 2112            var bestScore = 0.0;
 2113            var bestMatched = new List<string>();
 2114            var expected = template.ExpectedIntentNames;
 15115            foreach (var seq in patterns.Select(p => p.Sequence))
 3116            {
 26117                var overlap = seq.Count(s => expected.Any(e => string.Equals(e, s, StringComparison.OrdinalIgnoreCase)))
 3118                var score = expected.Count > 0 ? overlap / (double)expected.Count : 0;
 3119                if (score > bestScore)
 1120                {
 1121                    bestScore = score;
 10122                    bestMatched = seq.Where(s => expected.Any(e => string.Equals(e, s, StringComparison.OrdinalIgnoreCas
 1123                }
 3124            }
 2125            if (bestScore > 0)
 1126                matches.Add(new TemplateMatch(template.Name, bestScore, bestMatched));
 2127        }
 3128        return matches.OrderByDescending(m => m.Score).ToList();
 3129    }
 130
 131    private static DateTimeOffset TruncateToBucket(DateTimeOffset value, TimeSpan bucketSize)
 22132    {
 22133        var ticks = value.UtcTicks - (value.UtcTicks % bucketSize.Ticks);
 22134        return new DateTimeOffset(ticks, TimeSpan.Zero);
 22135    }
 136}