Die Betriebskontrolle im Testbetrieb meldete Stundendateien als fehlend, die es gab: 105 Eintraege in der Reihenfolge des Dateisystems, vierzeilig als eingeruecktes JSON. list liefert jetzt die 50 zuletzt geaenderten Eintraege, neueste zuerst, eine Zeile je Eintrag, mit Gesamtzahl im Kopf; pattern, sort, order und limit steuern das. Dabei aufgefallen (#675): Directory.GetFiles wertet Trennzeichen im Muster aus - pattern "../*" fuehrte an der Pfadpruefung vorbei aus dem Verzeichnis heraus, in list wie in read_many. Muster mit '/', '\' oder '..' werden jetzt abgewiesen. Und derselbe Befund ist keine zweite Aufgabe (#674): task_find beantwortet die Frage vor dem Anlegen, task_create legt bei gleichbedeutendem Titel einer offenen Aufgabe keine zweite an, sondern vermerkt das erneute Auftreten an der bestehenden. force=true geht trotzdem, Erledigtes blockiert nichts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
126 lines
5.2 KiB
C#
126 lines
5.2 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
|
|
namespace ClawdDotNet.Core.Tasks;
|
|
|
|
/// <summary>How closely two task titles describe the same thing.</summary>
|
|
public enum TaskTitleMatch
|
|
{
|
|
/// <summary>Different subjects.</summary>
|
|
None,
|
|
|
|
/// <summary>Related enough to be worth showing, not close enough to refuse a new task.</summary>
|
|
Similar,
|
|
|
|
/// <summary>The same finding said twice.</summary>
|
|
Duplicate
|
|
}
|
|
|
|
/// <summary>
|
|
/// Title comparison for the taskboard (#674).
|
|
///
|
|
/// <para>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.</para>
|
|
///
|
|
/// <para>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.</para>
|
|
/// </summary>
|
|
public static class TaskSimilarity
|
|
{
|
|
/// <summary>Jaccard overlap from which two titles count as the same finding.</summary>
|
|
public const double DuplicateThreshold = 0.8;
|
|
|
|
/// <summary>Overlap from which two titles are worth showing side by side.</summary>
|
|
public const double SimilarThreshold = 0.5;
|
|
|
|
/// <summary>Statuses that still ask for attention - only these can be duplicated.</summary>
|
|
public static bool IsOpen(TaskItemStatus status) => status is
|
|
TaskItemStatus.Backlog or TaskItemStatus.Todo or TaskItemStatus.InProgress
|
|
or TaskItemStatus.InReview or TaskItemStatus.Blocked;
|
|
|
|
/// <summary>Lower case, umlauts folded, punctuation dropped, whitespace collapsed.</summary>
|
|
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));
|
|
}
|
|
|
|
/// <summary>Word set of a normalised title.</summary>
|
|
private static HashSet<string> Words(string normalized)
|
|
=> new(normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.Ordinal);
|
|
|
|
/// <summary>How closely two titles match.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Open tasks whose title describes the same thing as <paramref name="title"/>,
|
|
/// closest match first. <paramref name="exceptId"/> skips the task being edited.
|
|
/// </summary>
|
|
public static List<(TaskItem Task, TaskTitleMatch Match)> FindOpen(
|
|
IEnumerable<TaskItem> 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();
|
|
}
|
|
}
|