FileRW list sortiert und gedeckelt (#673), Taskboard task_find (#674)

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:
Richard
2026-09-21 21:19:19 +02:00
co-authored by Claude Opus 5
parent 24e457b11f
commit 060dc6a6ad
10 changed files with 815 additions and 19 deletions
@@ -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();
}
}