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();
}
}