CI / build-and-test (push) Canceled after 0s
- UntisHubService.RecordRun: ein abgeschlossener Langzeit-Fehlzeitenabgleich schliesst die kurzfristige Kadenz derselben Gruppe automatisch mit ab (nicht umgekehrt). - Fehlzeitenabgleich-Dialog: neue "Uebernahme als"-ComboBox statt starrem Zielstatus, vorbelegt mit dem berechneten Vorschlag, aber frei aenderbar. - Neuer ai-backend-Endpunkt untis-status.php + AiPlanningService.RequestUntisStatusSuggestionsAsync: gebuendelter, anonymisierter KI-Statusvorschlag (nur Positions-Id + Rohsignale, nie Name/Klasse/ Datum), mit hartem Id-Mengen-Abgleich gegen Verwechslung. - Neue MCP-Tools (UntisComparisonTools): get_untis_hub_status, get_untis_absence_rows/ apply_untis_absence_status (anonymer Weg ueber ENr-Zuordnung) sowie get_named_untis_absence_pattern als bewusste, eng begrenzte Ausnahme (Name+Fehlzeiten fuer explizit angegebene Schueler-IDs, mit Bestaetigung ohne Sitzungsfreigabe - dafuer IMcpConfirmationService.ConfirmAsync um allowSessionTrust erweitert). - MapStatus/ENr-Zuordnung aus dem ViewModel in das neue, geteilte UntisLessonAbsenceHelper gezogen, damit Dialog und MCP-Tool nie unterschiedliche Statusvorschlaege berechnen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
99 lines
4.5 KiB
C#
99 lines
4.5 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Produktive <see cref="IMcpConfirmationService"/>-Implementierung: zeigt <see cref="McpConfirmDialog"/>
|
|
/// über dem Hauptfenster an. Der aufrufende Tool-Handler läuft auf einem Hintergrund-Thread
|
|
/// (MCP-Pipe-Session in <see cref="McpServerHostedService"/>), deshalb Marshalling über
|
|
/// <see cref="Dispatcher.UIThread"/>.
|
|
///
|
|
/// Nutzer-Feedback: der Dialog fiel zu wenig auf, wenn LehrerApp im Hintergrund lief (naheliegend,
|
|
/// da der Anstoß von einem KI-Client in einem anderen Fenster kommt) — deshalb wird das Hauptfenster
|
|
/// 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(AppLogger logger) : IMcpConfirmationService
|
|
{
|
|
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2);
|
|
|
|
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 allowSessionTrust = true)
|
|
{
|
|
if (allowSessionTrust)
|
|
{
|
|
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;
|
|
|
|
// Die gesamte Warte-/Timeout-Logik läuft als ein Stück innerhalb des UI-Thread-Callbacks:
|
|
// 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.
|
|
var result = await Dispatcher.UIThread.InvokeAsync(async () =>
|
|
{
|
|
if (owner.WindowState == WindowState.Minimized) owner.WindowState = WindowState.Normal;
|
|
owner.Activate();
|
|
|
|
var dialog = new McpConfirmDialog
|
|
{
|
|
DataContext = new McpConfirmDialogInfo
|
|
{
|
|
Title = title, Message = message, ConfirmText = "Übernehmen",
|
|
AllowSessionTrust = allowSessionTrust,
|
|
},
|
|
};
|
|
var dialogTask = dialog.ShowDialog<McpConfirmDialogResult>(owner);
|
|
var timeoutTask = Task.Delay(Timeout, ct);
|
|
var completed = await Task.WhenAny(dialogTask, timeoutTask);
|
|
if (completed != dialogTask)
|
|
{
|
|
dialog.Close(McpConfirmDialogResult.Rejected);
|
|
return McpConfirmDialogResult.Rejected;
|
|
}
|
|
return await dialogTask;
|
|
});
|
|
|
|
if (result.Approved && allowSessionTrust)
|
|
{
|
|
lock (_trustLock)
|
|
{
|
|
if (result.TrustAll) _trustAll = true;
|
|
else if (result.TrustOperation) _trustedOperations.Add(operationKey);
|
|
}
|
|
}
|
|
return result.Approved;
|
|
}
|
|
}
|