using System.Globalization; using System.Text; namespace ClawdDotNet.Core.Tasks; /// How closely two task titles describe the same thing. public enum TaskTitleMatch { /// Different subjects. None, /// Related enough to be worth showing, not close enough to refuse a new task. Similar, /// The same finding said twice. Duplicate } /// /// Title comparison for the taskboard (#674). /// /// Observed in the test operation: an agent that runs every three hours re-reported the /// same gap on every run. Six tasks said "Eur Kurse nicht aktuell", five said the news were /// missing. Nobody can tell from that whether a problem is new or three days old. /// /// The comparison is deliberately shallow - normalise, then compare word sets. Two titles /// that differ only in word order, case, umlaut spelling or punctuation are the same title; /// a title fully contained in a longer one is the same finding with more detail. Anything /// cleverer would be guesswork a cheap model cannot audit. /// public static class TaskSimilarity { /// Jaccard overlap from which two titles count as the same finding. public const double DuplicateThreshold = 0.8; /// Overlap from which two titles are worth showing side by side. public const double SimilarThreshold = 0.5; /// Statuses that still ask for attention - only these can be duplicated. public static bool IsOpen(TaskItemStatus status) => status is TaskItemStatus.Backlog or TaskItemStatus.Todo or TaskItemStatus.InProgress or TaskItemStatus.InReview or TaskItemStatus.Blocked; /// Lower case, umlauts folded, punctuation dropped, whitespace collapsed. public static string Normalize(string? title) { if (string.IsNullOrWhiteSpace(title)) return ""; var folded = new StringBuilder(title.Length); foreach (var c in title.ToLowerInvariant()) { switch (c) { case 'ä': folded.Append("ae"); break; case 'ö': folded.Append("oe"); break; case 'ü': folded.Append("ue"); break; case 'ß': folded.Append("ss"); break; default: folded.Append(c); break; } } // Remaining diacritics (é, ç, …) lose their marks rather than their letter. var decomposed = folded.ToString().Normalize(NormalizationForm.FormD); var plain = new StringBuilder(decomposed.Length); foreach (var c in decomposed) { if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark) continue; plain.Append(char.IsLetterOrDigit(c) ? c : ' '); } return string.Join(' ', plain.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries)); } /// Word set of a normalised title. private static HashSet Words(string normalized) => new(normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.Ordinal); /// How closely two titles match. public static TaskTitleMatch Compare(string? left, string? right) { var a = Normalize(left); var b = Normalize(right); if (a.Length == 0 || b.Length == 0) return TaskTitleMatch.None; if (a == b) return TaskTitleMatch.Duplicate; var wordsA = Words(a); var wordsB = Words(b); var shared = wordsA.Intersect(wordsB, StringComparer.Ordinal).Count(); if (shared == 0) return TaskTitleMatch.None; // "Betriebsluecke: Eur Kurse" inside "Betriebsluecke: Eur Kurse nicht aktuell" is the // same finding, told at different lengths. A single shared word is not - "Wetter" // would swallow every weather task there is. var (smaller, larger) = wordsA.Count <= wordsB.Count ? (wordsA, wordsB) : (wordsB, wordsA); if (smaller.Count >= 2 && shared == smaller.Count) return TaskTitleMatch.Duplicate; var overlap = (double)shared / (wordsA.Count + wordsB.Count - shared); if (overlap >= DuplicateThreshold) return TaskTitleMatch.Duplicate; if (overlap >= SimilarThreshold) return TaskTitleMatch.Similar; return TaskTitleMatch.None; } /// /// Open tasks whose title describes the same thing as , /// closest match first. skips the task being edited. /// public static List<(TaskItem Task, TaskTitleMatch Match)> FindOpen( IEnumerable tasks, string? title, string? exceptId = null) { var hits = new List<(TaskItem Task, TaskTitleMatch Match)>(); foreach (var task in tasks) { if (!IsOpen(task.Status)) continue; if (exceptId is not null && string.Equals(task.Id, exceptId, StringComparison.OrdinalIgnoreCase)) continue; var match = Compare(task.Title, title); if (match != TaskTitleMatch.None) hits.Add((task, match)); } return hits .OrderByDescending(h => h.Match) .ThenByDescending(h => h.Task.UpdatedAt) .ToList(); } }