feat: Sitzungsfreigabe für MCP-Bestätigungen + LessonStatus.Cancelled
Sitzungsfreigabe: McpConfirmDialog bietet zwei Checkboxen ("diese Art
von Aktion" / "alle KI-Aktionen für den Rest der Sitzung nicht mehr
nachfragen"), nur beim Bestätigen wirksam. IMcpConfirmationService.
ConfirmAsync bekommt einen per [CallerMemberName] automatisch
befüllten operationKey - kein bestehender Aufruf musste geändert
werden. Freigaben leben als In-Memory-Bookkeeping auf der
Confirmation-Service-Instanz, gelten bis App-Ende, werden aber trotzdem
geloggt, damit stillschweigende Bestätigungen nicht spurlos bleiben.
Grund: 17 identische Bestätigungen in Folge für eine neu generierte
Unterrichtseinheit sind unzumutbar.
LessonStatus.Cancelled ("Ausgefallen") als Alternative zu
delete_lesson für Stunden, die nur ausgefallen sind (Exkursion,
Feiertag), aber als Ereignis dokumentiert bleiben sollen. Als
Cancelled=4 angehängt (bestehende LiteDB-Werte 0/1 sind fix). Konsistent
wie "bereits durchgeführt" behandelt an allen Stellen, die bisher nur
auf Conducted prüften: Terminverschiebung, Fortschrittsanzeige,
Hausaufgabe-kontrollieren-Erinnerung, "nächste Stunde"-Vorschlag.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Views.Mcp;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp;
|
||||
@@ -17,16 +19,38 @@ namespace LehrerApp.Desktop.Services.Mcp;
|
||||
/// vor dem Anzeigen aus einer möglichen Minimierung geholt und aktiviert, und der Dialog selbst
|
||||
/// läuft `Topmost`.
|
||||
///
|
||||
/// Nutzer-Feedback (Nachtrag): bei vielen gleichartigen Vorschlägen in Folge (z.B. 17x
|
||||
/// "add_lesson_phase" für eine neu generierte Unterrichtseinheit) einzeln nachfragen zu müssen, ist
|
||||
/// unzumutbar. Der Dialog bietet deshalb zwei Sitzungsfreigaben an ("diese Aktion" / "alle
|
||||
/// Aktionen"), die als reines In-Memory-Bookkeeping auf dieser Singleton-Instanz leben — sie gelten
|
||||
/// bis zum Beenden der App (neuer Prozess = neue Instanz = wieder alles ungetraut) und werden nie
|
||||
/// persistiert. <paramref name="operationKey"/> ist der Name der aufrufenden Tool-Methode (siehe
|
||||
/// <see cref="IMcpConfirmationService.ConfirmAsync"/>), nicht der MCP-Wire-Name.
|
||||
///
|
||||
/// Ohne Reaktion des Nutzers würde die Pipe-Session (und damit der wartende KI-Client) unbegrenzt
|
||||
/// hängen bleiben — nach <see cref="Timeout"/> wird der Dialog automatisch geschlossen und die
|
||||
/// Änderung als abgelehnt gewertet.
|
||||
/// </summary>
|
||||
public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService
|
||||
public sealed class AvaloniaMcpConfirmationService(AppLogger logger) : IMcpConfirmationService
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2);
|
||||
|
||||
public async Task<bool> ConfirmAsync(string title, string message, CancellationToken ct)
|
||||
private readonly object _trustLock = new();
|
||||
private readonly HashSet<string> _trustedOperations = [];
|
||||
private bool _trustAll;
|
||||
|
||||
public async Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
|
||||
[CallerMemberName] string operationKey = "")
|
||||
{
|
||||
bool alreadyTrusted;
|
||||
lock (_trustLock) alreadyTrusted = _trustAll || _trustedOperations.Contains(operationKey);
|
||||
if (alreadyTrusted)
|
||||
{
|
||||
logger.Info($"MCP: „{title}“ automatisch bestätigt (Sitzungsfreigabe für " +
|
||||
$"{(_trustAll ? "alle Aktionen" : operationKey)}).");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
|
||||
return false;
|
||||
|
||||
@@ -34,7 +58,7 @@ public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService
|
||||
// Avalonias Dispatcher-Synchronisationskontext sorgt dafür, dass die Fortsetzung nach
|
||||
// "await Task.WhenAny(...)" wieder auf dem UI-Thread läuft, sodass dialog.Close() dort
|
||||
// sicher aufgerufen werden kann.
|
||||
return await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
var result = await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
if (owner.WindowState == WindowState.Minimized) owner.WindowState = WindowState.Normal;
|
||||
owner.Activate();
|
||||
@@ -43,15 +67,25 @@ public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService
|
||||
{
|
||||
DataContext = new McpConfirmDialogInfo { Title = title, Message = message, ConfirmText = "Übernehmen" },
|
||||
};
|
||||
var dialogTask = dialog.ShowDialog<bool>(owner);
|
||||
var dialogTask = dialog.ShowDialog<McpConfirmDialogResult>(owner);
|
||||
var timeoutTask = Task.Delay(Timeout, ct);
|
||||
var completed = await Task.WhenAny(dialogTask, timeoutTask);
|
||||
if (completed != dialogTask)
|
||||
{
|
||||
dialog.Close(false);
|
||||
return false;
|
||||
dialog.Close(McpConfirmDialogResult.Rejected);
|
||||
return McpConfirmDialogResult.Rejected;
|
||||
}
|
||||
return await dialogTask;
|
||||
});
|
||||
|
||||
if (result.Approved)
|
||||
{
|
||||
lock (_trustLock)
|
||||
{
|
||||
if (result.TrustAll) _trustAll = true;
|
||||
else if (result.TrustOperation) _trustedOperations.Add(operationKey);
|
||||
}
|
||||
}
|
||||
return result.Approved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp;
|
||||
|
||||
/// <summary>
|
||||
@@ -9,7 +11,14 @@ namespace LehrerApp.Desktop.Services.Mcp;
|
||||
/// </summary>
|
||||
public interface IMcpConfirmationService
|
||||
{
|
||||
/// <returns>true, wenn der Nutzer bestätigt hat; false bei Ablehnung, Timeout oder falls kein
|
||||
/// Hauptfenster verfügbar ist (z.B. während des DB-Passwort-Prompts beim Start).</returns>
|
||||
Task<bool> ConfirmAsync(string title, string message, CancellationToken ct);
|
||||
/// <param name="operationKey">Identifiziert die Art der Operation für eine mögliche
|
||||
/// Sitzungsfreigabe ("diese Aktion für den Rest der Sitzung nicht mehr nachfragen") — bewusst
|
||||
/// per <see cref="CallerMemberNameAttribute"/> automatisch befüllt (der Name der aufrufenden
|
||||
/// Tool-Methode, z.B. "AddLessonPhase"), damit kein Aufrufer diesen Parameter selbst pflegen
|
||||
/// muss. Nicht Teil des MCP-Wire-Protokolls, rein internes Bestätigungs-Bookkeeping.</param>
|
||||
/// <returns>true, wenn der Nutzer bestätigt hat (direkt oder über eine bereits erteilte
|
||||
/// Sitzungsfreigabe); false bei Ablehnung, Timeout oder falls kein Hauptfenster verfügbar ist
|
||||
/// (z.B. während des DB-Passwort-Prompts beim Start).</returns>
|
||||
Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
|
||||
[CallerMemberName] string operationKey = "");
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ public class LessonPlanTools(
|
||||
[Description("ID der Einzelstunde.")] Guid lessonId,
|
||||
[Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null,
|
||||
[Description("Neue Hausaufgabe. Unverändert lassen: weglassen.")] string? homework = null,
|
||||
[Description("Neuer Status: Planned, Conducted, Draft oder Ready. Unverändert lassen: weglassen.")] LessonStatus? status = null,
|
||||
[Description("Neuer Status: Planned, Conducted, Draft, Ready oder Cancelled (ausgefallen, z.B. Exkursion/Feiertag - Alternative zu delete_lesson, wenn die Stunde als Ereignis dokumentiert bleiben soll). Unverändert lassen: weglassen.")] LessonStatus? status = null,
|
||||
[Description("Neuer Stundenbeginn, Format HH:mm. Unverändert lassen: weglassen.")] TimeOnly? startTime = null,
|
||||
[Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null,
|
||||
CancellationToken ct = default)
|
||||
@@ -314,8 +314,11 @@ public class LessonPlanTools(
|
||||
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
|
||||
|
||||
var oldDate = lesson.Date;
|
||||
// Muss exakt dieselbe Schutzbedingung wie LessonSchedulingService.Move verwenden, sonst
|
||||
// weicht die Vorschau-Zahl im Bestätigungstext von dem ab, was tatsächlich verschoben wird.
|
||||
var affectedCount = shiftFollowingLessons
|
||||
? lessons.GetByUnit(lesson.UnitId).Count(l => l.Id != lesson.Id && l.Status != LessonStatus.Conducted && l.Date > oldDate)
|
||||
? lessons.GetByUnit(lesson.UnitId).Count(l =>
|
||||
l.Id != lesson.Id && l.Status is not (LessonStatus.Conducted or LessonStatus.Cancelled) && l.Date > oldDate)
|
||||
: 0;
|
||||
|
||||
var message = $"„{lesson.Topic}“ von {oldDate:dd.MM.yyyy} auf {newDate:dd.MM.yyyy} verschieben?" +
|
||||
@@ -340,7 +343,7 @@ public class LessonPlanTools(
|
||||
return new WriteResultDto(true, lesson.Id, "Stunde verschoben.");
|
||||
}
|
||||
|
||||
[Description("Löscht eine Einzelstunde endgültig, inklusive ihrer Anhänge. Anders als die meisten anderen Löschvorgänge in LehrerApp landet eine gelöschte Stunde NICHT im Papierkorb — nicht rückgängig zu machen. Muss der Nutzer erst bestätigen.")]
|
||||
[Description("Löscht eine Einzelstunde endgültig, inklusive ihrer Anhänge. Anders als die meisten anderen Löschvorgänge in LehrerApp landet eine gelöschte Stunde NICHT im Papierkorb — nicht rückgängig zu machen. Für eine Stunde, die nur ausgefallen ist (Exkursion, Feiertag, Vertretung ohne Ersatztermin) aber als Ereignis dokumentiert bleiben soll, ist update_lesson mit status=Cancelled meist die bessere Wahl. Muss der Nutzer erst bestätigen.")]
|
||||
public async Task<WriteResultDto> DeleteLesson(
|
||||
[Description("ID der zu löschenden Einzelstunde.")] Guid lessonId,
|
||||
CancellationToken ct = default)
|
||||
|
||||
Reference in New Issue
Block a user