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>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user