feat: Untis-Hub-Kadenzkopplung, anpassbarer Statusvorschlag + KI-/MCP-Unterstuetzung beim Fehlzeitenabgleich (Nutzer-Feedback)
CI / build-and-test (push) Canceled after 0s
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>
This commit is contained in:
@@ -227,6 +227,7 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<GroupMembershipTools>();
|
||||
services.AddSingleton<LetterTemplateTools>();
|
||||
services.AddSingleton<CompetencyTools>();
|
||||
services.AddSingleton<UntisComparisonTools>();
|
||||
services.AddSingleton<McpServerHostedService>();
|
||||
services.AddSingleton<McpClientRegistrationService>();
|
||||
|
||||
|
||||
@@ -605,6 +605,59 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fragt für eine Charge anonymisierter Fehlzeitenzeilen (ai-backend/untis-status.php,
|
||||
/// Nutzer-Feedback zum Untis-Hub) einen Statusvorschlag ab - eine Anfrage für alle fraglichen
|
||||
/// Zeilen eines Abgleichslaufs statt einer je Zeile (Kosten/Latenz-Überlegung aus der
|
||||
/// Nutzerdiskussion). Liefert nur dann Vorschläge zurück, wenn die vom Backend gemeldete
|
||||
/// Id-Menge exakt der gesendeten entspricht (keine fehlenden, zusätzlichen oder doppelten Ids) -
|
||||
/// andernfalls eine leere Zuordnung, statt sich auf eine möglicherweise vermischte Reihenfolge
|
||||
/// zu verlassen. Der Aufrufer behält für jede nicht zurückgelieferte Id den bisherigen
|
||||
/// regelbasierten Status bei.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyDictionary<string, string>> RequestUntisStatusSuggestionsAsync(
|
||||
IReadOnlyList<AiUntisStatusRow> rows, string token)
|
||||
{
|
||||
if (rows.Count == 0) return new Dictionary<string, string>();
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "untis-status.php")
|
||||
{
|
||||
Content = JsonContent.Create(new AiUntisStatusRequest { Rows = rows.ToList() }, options: JsonOptions),
|
||||
};
|
||||
req.Headers.Authorization = new("Bearer", token);
|
||||
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await http.SendAsync(req); }
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
||||
}
|
||||
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||||
throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
|
||||
if (resp.StatusCode == (HttpStatusCode)402)
|
||||
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw await BuildRequestFailedExceptionAsync(resp);
|
||||
|
||||
AiUntisStatusResponse? result;
|
||||
try { result = await resp.Content.ReadFromJsonAsync<AiUntisStatusResponse>(JsonOptions); }
|
||||
catch (Exception ex) when (ex is not AiBackendException)
|
||||
{
|
||||
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
|
||||
}
|
||||
if (result is null)
|
||||
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
|
||||
|
||||
var sentIds = rows.Select(r => r.Id).ToHashSet();
|
||||
var receivedIds = result.Suggestions.Select(s => s.Id).ToList();
|
||||
if (receivedIds.Count != sentIds.Count || receivedIds.Distinct().Count() != receivedIds.Count
|
||||
|| !sentIds.SetEquals(receivedIds))
|
||||
return new Dictionary<string, string>();
|
||||
|
||||
return result.Suggestions.ToDictionary(s => s.Id, s => s.Status);
|
||||
}
|
||||
|
||||
private class LoginResult { public string Token { get; set; } = ""; }
|
||||
private class BalanceResult { public decimal BalanceUsd { get; set; } }
|
||||
private class BackendErrorResult
|
||||
|
||||
@@ -40,15 +40,18 @@ public sealed class AvaloniaMcpConfirmationService(AppLogger logger) : IMcpConfi
|
||||
private bool _trustAll;
|
||||
|
||||
public async Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
|
||||
[CallerMemberName] string operationKey = "")
|
||||
[CallerMemberName] string operationKey = "", bool allowSessionTrust = true)
|
||||
{
|
||||
bool alreadyTrusted;
|
||||
lock (_trustLock) alreadyTrusted = _trustAll || _trustedOperations.Contains(operationKey);
|
||||
if (alreadyTrusted)
|
||||
if (allowSessionTrust)
|
||||
{
|
||||
logger.Info($"MCP: „{title}“ automatisch bestätigt (Sitzungsfreigabe für " +
|
||||
$"{(_trustAll ? "alle Aktionen" : operationKey)}).");
|
||||
return true;
|
||||
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 })
|
||||
@@ -65,7 +68,11 @@ public sealed class AvaloniaMcpConfirmationService(AppLogger logger) : IMcpConfi
|
||||
|
||||
var dialog = new McpConfirmDialog
|
||||
{
|
||||
DataContext = new McpConfirmDialogInfo { Title = title, Message = message, ConfirmText = "Übernehmen" },
|
||||
DataContext = new McpConfirmDialogInfo
|
||||
{
|
||||
Title = title, Message = message, ConfirmText = "Übernehmen",
|
||||
AllowSessionTrust = allowSessionTrust,
|
||||
},
|
||||
};
|
||||
var dialogTask = dialog.ShowDialog<McpConfirmDialogResult>(owner);
|
||||
var timeoutTask = Task.Delay(Timeout, ct);
|
||||
@@ -78,7 +85,7 @@ public sealed class AvaloniaMcpConfirmationService(AppLogger logger) : IMcpConfi
|
||||
return await dialogTask;
|
||||
});
|
||||
|
||||
if (result.Approved)
|
||||
if (result.Approved && allowSessionTrust)
|
||||
{
|
||||
lock (_trustLock)
|
||||
{
|
||||
|
||||
@@ -16,9 +16,15 @@ public interface IMcpConfirmationService
|
||||
/// 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>
|
||||
/// <param name="allowSessionTrust">False für die eine bewusste Ausnahme, bei der eine
|
||||
/// Sitzungsfreigabe nicht angeboten werden soll (Nutzer-Entscheidung zu
|
||||
/// <c>get_named_untis_absence_pattern</c>: eine namentliche Fehlzeitenauskunft muss JEDES MAL
|
||||
/// einzeln bestätigt werden, nie pauschal für die restliche Sitzung) — weder die vorherige
|
||||
/// Prüfung auf eine bereits bestehende Freigabe noch das Setzen einer neuen finden dann statt,
|
||||
/// unabhängig davon, ob zuvor schon "alle Aktionen" freigegeben wurde.</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 = "");
|
||||
[CallerMemberName] string operationKey = "", bool allowSessionTrust = true);
|
||||
}
|
||||
|
||||
@@ -32,13 +32,13 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
||||
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
||||
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
||||
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools,
|
||||
CompetencyTools competencyTools)
|
||||
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools)
|
||||
{
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
_serverOptions = BuildServerOptions(
|
||||
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
|
||||
groupMembershipTools, letterTemplateTools, competencyTools);
|
||||
groupMembershipTools, letterTemplateTools, competencyTools, untisComparisonTools);
|
||||
}
|
||||
|
||||
/// <summary>Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls
|
||||
@@ -115,7 +115,7 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
||||
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
||||
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
||||
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools,
|
||||
CompetencyTools competencyTools)
|
||||
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools)
|
||||
{
|
||||
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
|
||||
@@ -177,6 +177,12 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
||||
"Listet alle Fächer.");
|
||||
AddReadTool(competencyTools.GetCompetencyCatalog, "get_competency_catalog",
|
||||
"Listet den Kompetenzkatalog eines Fachs, optional gefiltert auf eine Klassenstufe.");
|
||||
AddReadTool(untisComparisonTools.GetUntisHubStatus, "get_untis_hub_status",
|
||||
"Listet die Fälligkeit der Untis-Hub-Abgleiche, ohne selbst WebUntis anzufragen.");
|
||||
AddReadTool(untisComparisonTools.GetUntisAbsenceRows, "get_untis_absence_rows",
|
||||
"Listet anonymisierte Fehlzeiten-Diskrepanzen einer Lerngruppe gegenüber WebUntis (keine Schülernamen, nur eine row-id je Zeile).");
|
||||
AddReadTool(untisComparisonTools.GetNamedUntisAbsencePattern, "get_named_untis_absence_pattern",
|
||||
"Liefert Fehlzeiten MIT Schülername für explizit angegebene Schüler-IDs - Ausnahme von der sonstigen Anonymisierung, erfordert jedes Mal eine gesonderte Nutzerbestätigung.");
|
||||
|
||||
AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry",
|
||||
"Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig).");
|
||||
@@ -220,6 +226,8 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
||||
"Ändert Code/Beschreibung einer Einzelkompetenz (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(competencyTools.RemoveCompetencyItem, "remove_competency_item",
|
||||
"Entfernt eine Einzelkompetenz aus einem Kompetenzbereich (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(untisComparisonTools.ApplyUntisAbsenceStatus, "apply_untis_absence_status",
|
||||
"Übernimmt einen Statusvorschlag für eine über get_untis_absence_rows gelieferte row-id (Bestätigung durch den Nutzer nötig, nennt keinen Schülernamen).");
|
||||
|
||||
AddDestructiveWriteTool(lessonPlanTools.DeleteLesson, "delete_lesson",
|
||||
"Löscht eine Einzelstunde endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
|
||||
|
||||
@@ -7,6 +7,12 @@ namespace LehrerApp.Desktop.Services.Mcp;
|
||||
/// Tool-Klasse referenziert — ein KI-Client kann diese Daten technisch nicht erreichen, unabhängig
|
||||
/// davon, wie vertrauenswürdig der lokale Modell-Client erscheint oder wie die Tool-Liste künftig
|
||||
/// wächst. <see cref="McpServerHostedService"/> registriert nur exakt diese Namen.
|
||||
///
|
||||
/// "get_named_untis_absence_pattern" ist die eine bewusste, eng begrenzte Ausnahme von der oben
|
||||
/// beschriebenen Regel (Nutzer-Entscheidung, siehe TODO.md): es verknüpft Schülername mit
|
||||
/// Fehlzeitendaten, aber nur für explizit angegebene Schüler-IDs und nur nach jedes Mal gesonderter,
|
||||
/// prominenter Bestätigung ohne Sitzungsfreigabe (siehe UntisComparisonTools). Documentation/
|
||||
/// Vorgang bleibt davon unberührt weiterhin vollständig ausgeschlossen.
|
||||
/// </summary>
|
||||
public static class McpToolScope
|
||||
{
|
||||
@@ -23,6 +29,9 @@ public static class McpToolScope
|
||||
"render_letter",
|
||||
"get_subjects",
|
||||
"get_competency_catalog",
|
||||
"get_untis_hub_status",
|
||||
"get_untis_absence_rows",
|
||||
"get_named_untis_absence_pattern",
|
||||
];
|
||||
|
||||
/// <summary>Write-Tools (Phase 2+3) — jeder Aufruf läuft über <see cref="IMcpConfirmationService"/>,
|
||||
@@ -53,6 +62,7 @@ public static class McpToolScope
|
||||
"add_competency_item",
|
||||
"update_competency_item",
|
||||
"remove_competency_item",
|
||||
"apply_untis_absence_status",
|
||||
];
|
||||
|
||||
/// <summary>Löschende Write-Tools — ursprünglich eine bewusste, gezielte Ausnahme von der sonst
|
||||
|
||||
@@ -69,3 +69,29 @@ public record PlaceholderInfoDto(string Name, string Type, bool Required, bool I
|
||||
public record LetterTemplateDto(string Id, string Name, string Description, List<PlaceholderInfoDto> Placeholders);
|
||||
|
||||
public record LetterRenderResultDto(bool Success, string Message, string? Base64Pdf, string? SuggestedFileName);
|
||||
|
||||
/// <summary>Eine Zeile des Untis-Hub (siehe UntisHubService) - "Kind"/"DueState" als Text statt
|
||||
/// Enum-Wert, damit ein KI-Client sie ohne Kenntnis des internen Enums lesen kann.</summary>
|
||||
public record UntisHubStatusRowDto(
|
||||
string Kind, string GroupName, string DueState, string DueLabel,
|
||||
DateTime? LastRunAt, string? LastResultSummary);
|
||||
|
||||
/// <summary>Anonymisierte Fehlzeiten-Diskrepanz (siehe UntisComparisonTools.GetUntisAbsenceRows):
|
||||
/// bewusst KEIN Schülername/keine Klasse - <see cref="RowId"/> ist die einzige Kennung, über die
|
||||
/// UntisComparisonTools.ApplyUntisAbsenceStatus später zurückordnet.</summary>
|
||||
public record UntisAbsenceRowDto(
|
||||
string RowId, DateOnly Date, string ReasonText, int AbsentMinutes, bool HandledOn,
|
||||
bool? ExternKeyInParentheses, string CurrentLocalStatus, string CurrentGuessStatus);
|
||||
|
||||
/// <summary>Eine Zeile aus dem namentlichen Ausnahmeweg (get_named_untis_absence_pattern) - im
|
||||
/// Unterschied zu <see cref="UntisAbsenceRowDto"/> bewusst MIT Schülername, da genau diese
|
||||
/// Zusammenführung von Name und Fehlzeitendaten der Zweck des Aufrufs ist (z.B. Fehlmuster-Vergleich
|
||||
/// zwischen zwei Schülern) und der Nutzer sie je Anfrage einzeln freigegeben hat.</summary>
|
||||
public record NamedUntisAbsenceRowDto(
|
||||
Guid StudentId, string StudentFullName, DateOnly Date, string ReasonText,
|
||||
string CurrentLocalStatus, string CurrentGuessStatus);
|
||||
|
||||
/// <summary><see cref="Granted"/> ist false bei Ablehnung/Timeout oder wenn keine der angegebenen
|
||||
/// Schüler-IDs bekannt war - <see cref="Rows"/> ist dann null, nicht nur leer, damit ein KI-Client
|
||||
/// "abgelehnt" nicht mit "keine Fehlzeiten gefunden" verwechselt.</summary>
|
||||
public record NamedUntisAbsenceResultDto(bool Granted, string Message, List<NamedUntisAbsenceRowDto>? Rows);
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// MCP-Tools für die WebUntis-Abgleiche (Nutzer-Nachtrag zum Untis-Hub, siehe TODO.md), zweigleisig
|
||||
/// wie vom Nutzer entschieden:
|
||||
///
|
||||
/// - <see cref="GetUntisAbsenceRows"/>/<see cref="ApplyUntisAbsenceStatus"/> (bevorzugter Weg): eine
|
||||
/// rein technische <c>row-id</c> ordnet zurück, nie ein Schülername - nur Zeilen, die WebUntis
|
||||
/// selbst eindeutig über die externe Schülernummer (ENr) einem Kursmitglied zuordnet, werden
|
||||
/// überhaupt gelistet (unklare, nur namensbasiert auflösbare Fälle fließen bewusst NICHT hier
|
||||
/// hinein, siehe <see cref="GetUntisAbsenceRows"/>).
|
||||
/// - <see cref="GetNamedUntisAbsencePattern"/> (bewusste, eng begrenzte Ausnahme von der sonst in
|
||||
/// <see cref="McpToolScope"/> geltenden Regel, dass personenbezogene Verhaltens-/Anwesenheitsdaten
|
||||
/// nie mit einem Namen verknüpft nach außen gehen): exponiert Name UND Fehlzeiten gemeinsam, aber
|
||||
/// nur für explizit angegebene Schüler-IDs und nur nach JEDES MAL gesonderter, prominenter
|
||||
/// Bestätigung ohne Sitzungsfreigabe (<c>allowSessionTrust: false</c>).
|
||||
///
|
||||
/// <see cref="GetUntisHubStatus"/> ergänzt beide Wege um einen Überblick, welche Abgleiche laut
|
||||
/// <see cref="UntisHubService"/> überhaupt fällig sind, ohne selbst WebUntis anzufragen.
|
||||
/// </summary>
|
||||
public class UntisComparisonTools(
|
||||
IGroupRepository groups, IStudentRepository students, IParticipationSessionRepository sessions,
|
||||
IParticipationRepository participation, WebUntisIntegrationService untis, UntisHubService hub,
|
||||
IMcpConfirmationService confirmation)
|
||||
{
|
||||
// Statuswerte, die ApplyUntisAbsenceStatus akzeptiert - dieselbe Einschränkung wie
|
||||
// WebUntisLessonAbsenceRow.SelectableStatuses im interaktiven Dialog (nicht z.B. "Geschwänzt"
|
||||
// oder "Suspendiert", die WebUntis hier nie meldet). Eigenständig gehalten statt der Row-Klasse
|
||||
// referenziert, da Tool-Klassen unter Services/Mcp nicht von ViewModel-Klassen abhängen sollen
|
||||
// (siehe GradeTools).
|
||||
private static readonly AttendanceStatus[] SelectableStatuses =
|
||||
[
|
||||
AttendanceStatus.ExcusePending, AttendanceStatus.Excused, AttendanceStatus.Unexcused,
|
||||
AttendanceStatus.Late, AttendanceStatus.LeftDuringClass, AttendanceStatus.Present,
|
||||
];
|
||||
|
||||
// In-Memory, pro Prozesslaufzeit - eine row-id aus GetUntisAbsenceRows ist nur bis zum nächsten
|
||||
// Neustart von LehrerApp gültig; danach muss der KI-Client die Liste erneut abrufen. Bewusst
|
||||
// keine Ablauf-/Größenbegrenzung (siehe Nutzerdiskussion: geringe Nutzungsfrequenz, winzige
|
||||
// Einträge) - ein v1-Kompromiss, kein Deployment-Risiko wie bei den ai-backend-Endpunkten.
|
||||
private readonly ConcurrentDictionary<string, PendingAbsenceRow> _pendingRows = new();
|
||||
|
||||
private sealed record PendingAbsenceRow(Guid StudentId, Guid SessionId, Guid GroupId, DateOnly Date);
|
||||
|
||||
[Description("Listet die Fälligkeit der Untis-Hub-Abgleiche (Fehlzeiten je Lerngruppe, offene Stunden, Klassenbuch-/Hausaufgabenabgleich) - reine Lesefunktion aus der lokalen Fälligkeits-Historie, kein eigener WebUntis-Zugriff.")]
|
||||
public List<UntisHubStatusRowDto> GetUntisHubStatus() =>
|
||||
hub.GetRows().Select(r => new UntisHubStatusRowDto(
|
||||
r.Kind.ToString(), r.GroupName, r.DueState.ToString(), r.DueLabel, r.LastRunAt, r.LastResultSummary)).ToList();
|
||||
|
||||
[Description("""
|
||||
Listet Fehlzeiten-Diskrepanzen einer Lerngruppe gegenüber WebUntis in einem Zeitraum, ANONYMISIERT:
|
||||
enthält keinen Schülernamen, nur eine technische row-id je Zeile (für apply_untis_absence_status).
|
||||
Enthält nur Zeilen, die WebUntis über die externe Schülernummer eindeutig einem Kursmitglied zuordnen
|
||||
konnte - Zeilen, die nur über den Namen auflösbar wären, fehlen hier bewusst; für die braucht es
|
||||
get_named_untis_absence_pattern (Namen exponierend, gesondert bestätigungspflichtig).
|
||||
""")]
|
||||
public async Task<List<UntisAbsenceRowDto>> GetUntisAbsenceRows(
|
||||
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||
[Description("Startdatum, Format YYYY-MM-DD.")] DateOnly startDate,
|
||||
[Description("Enddatum, Format YYYY-MM-DD.")] DateOnly endDate,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var group = groups.GetById(groupId);
|
||||
if (group?.WebUntisLessonId is not { } lessonId) return [];
|
||||
|
||||
var courseStudents = students.GetByGroup(groupId);
|
||||
var byExternKey = courseStudents
|
||||
.Select(s => (Student: s, Key: UntisLessonAbsenceHelper.StudentExternKey(s)))
|
||||
.Where(x => x.Key is not null)
|
||||
.ToDictionary(x => x.Key!.Value, x => x.Student);
|
||||
var localSessions = sessions.GetByGroup(groupId)
|
||||
.Where(s => s.Date >= startDate && s.Date <= endDate)
|
||||
.GroupBy(s => s.Date).ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
var absences = await untis.GetLessonAbsencesAsync(lessonId, startDate, endDate, ct);
|
||||
var rows = new List<UntisAbsenceRowDto>();
|
||||
foreach (var absence in absences)
|
||||
{
|
||||
if (absence.ExternKey is not { } key || !byExternKey.TryGetValue(key, out var student)) continue;
|
||||
if (!TryParseDate(absence.Date, out var date) || !localSessions.TryGetValue(date, out var session)) continue;
|
||||
|
||||
var rowId = Guid.NewGuid().ToString("N");
|
||||
_pendingRows[rowId] = new PendingAbsenceRow(student.Id, session.Id, groupId, date);
|
||||
|
||||
var entry = participation.GetBySessionAndStudent(session.Id, student.Id);
|
||||
var guess = UntisLessonAbsenceHelper.MapStatus(absence);
|
||||
rows.Add(new UntisAbsenceRowDto(
|
||||
rowId, date, absence.Reason ?? "", absence.AbsentMinutes,
|
||||
!string.IsNullOrWhiteSpace(absence.HandledOn),
|
||||
absence.ExternKey is null ? null : absence.ExternKeyInParentheses,
|
||||
LocalStatusLabel(entry?.Attendance), guess.ToString()));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
[Description("Übernimmt einen Statusvorschlag für eine über get_untis_absence_rows gelieferte row-id in den lokalen Anwesenheitsstatus. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen; die Bestätigungsmeldung nennt bewusst KEINEN Schülernamen (nur Datum, Lerngruppe, Zielstatus).")]
|
||||
public async Task<WriteResultDto> ApplyUntisAbsenceStatus(
|
||||
[Description("row-id aus get_untis_absence_rows.")] string rowId,
|
||||
[Description("Zielstatus: Present, Late, LeftDuringClass, ExcusePending, Excused oder Unexcused.")] string status,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!Enum.TryParse<AttendanceStatus>(status, out var target) || !SelectableStatuses.Contains(target))
|
||||
return new WriteResultDto(false, null, "Unbekannter oder nicht zulässiger Status.");
|
||||
if (!_pendingRows.TryGetValue(rowId, out var row))
|
||||
return new WriteResultDto(false, null, "Unbekannte oder abgelaufene row-id - zuerst get_untis_absence_rows erneut aufrufen.");
|
||||
|
||||
var groupName = groups.GetById(row.GroupId)?.Name ?? "?";
|
||||
var message = $"Fehlzeile vom {row.Date:dd.MM.yyyy} in Lerngruppe „{groupName}“: " +
|
||||
$"Anwesenheitsstatus auf „{LocalStatusLabel(target)}“ setzen?";
|
||||
if (!await confirmation.ConfirmAsync("Fehlzeiten-Status übernehmen?", message, ct))
|
||||
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||
|
||||
var entry = participation.GetBySessionAndStudent(row.SessionId, row.StudentId)
|
||||
?? new ParticipationEntry { SessionId = row.SessionId, StudentId = row.StudentId };
|
||||
entry.Attendance = target;
|
||||
entry.UpdatedAt = DateTime.UtcNow;
|
||||
participation.Save(entry);
|
||||
_pendingRows.TryRemove(rowId, out _);
|
||||
return new WriteResultDto(true, entry.Id, "Status übernommen.");
|
||||
}
|
||||
|
||||
[Description("""
|
||||
Liefert Fehlzeiten für EXPLIZIT angegebene Schüler-IDs MIT Namen (z.B. für einen Bericht oder einen
|
||||
Fehlmuster-Vergleich zwischen zwei Schülern) - bewusste, eng begrenzte Ausnahme von der sonst
|
||||
geltenden Anonymisierung (siehe get_untis_absence_rows). So wenige studentIds wie für die Anfrage
|
||||
nötig angeben, nicht den ganzen Kurs. Erfordert JEDES MAL eine gesonderte, prominente
|
||||
Nutzerbestätigung ohne Sitzungsfreigabe - liefert bei Ablehnung granted:false und keine Zeilen.
|
||||
""")]
|
||||
public async Task<NamedUntisAbsenceResultDto> GetNamedUntisAbsencePattern(
|
||||
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||
[Description("Ids der Schüler, für die Name UND Fehlzeiten gemeinsam offengelegt werden sollen.")] List<Guid> studentIds,
|
||||
[Description("Startdatum, Format YYYY-MM-DD.")] DateOnly startDate,
|
||||
[Description("Enddatum, Format YYYY-MM-DD.")] DateOnly endDate,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var group = groups.GetById(groupId);
|
||||
if (group?.WebUntisLessonId is not { } lessonId)
|
||||
return new NamedUntisAbsenceResultDto(false, "Für diese Lerngruppe ist keine WebUntis-Unterrichtsnummer hinterlegt.", null);
|
||||
|
||||
var resolvedStudents = studentIds.Distinct()
|
||||
.Select(students.GetById).Where(s => s is not null).Cast<Student>().ToList();
|
||||
if (resolvedStudents.Count == 0)
|
||||
return new NamedUntisAbsenceResultDto(false, "Keine der angegebenen Schüler-IDs ist bekannt.", null);
|
||||
|
||||
var names = string.Join(", ", resolvedStudents.Select(s => s.FullName));
|
||||
var message = $"Name UND Fehlzeiten gemeinsam an den KI-Assistenten weitergeben für:\n{names}\n\n" +
|
||||
$"Zeitraum: {startDate:dd.MM.yyyy}–{endDate:dd.MM.yyyy}, Lerngruppe „{group.Name}“.";
|
||||
if (!await confirmation.ConfirmAsync("Namentliche Fehlzeitenauskunft freigeben?", message, ct, allowSessionTrust: false))
|
||||
return new NamedUntisAbsenceResultDto(false, "Vom Nutzer abgelehnt oder nicht bestätigt.", null);
|
||||
|
||||
var byExternKey = resolvedStudents
|
||||
.Select(s => (Student: s, Key: UntisLessonAbsenceHelper.StudentExternKey(s)))
|
||||
.Where(x => x.Key is not null)
|
||||
.ToDictionary(x => x.Key!.Value, x => x.Student);
|
||||
var byName = resolvedStudents
|
||||
.SelectMany(s => new[]
|
||||
{
|
||||
NameKey($"{s.LastName} {s.FirstName}"), NameKey($"{s.FirstName} {s.LastName}"),
|
||||
}.Select(key => (Key: key, Student: s)))
|
||||
.GroupBy(x => x.Key).Where(g => g.Select(x => x.Student).Distinct().Count() == 1)
|
||||
.ToDictionary(g => g.Key, g => g.First().Student);
|
||||
var localSessions = sessions.GetByGroup(groupId)
|
||||
.Where(s => s.Date >= startDate && s.Date <= endDate)
|
||||
.GroupBy(s => s.Date).ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
var absences = await untis.GetLessonAbsencesAsync(lessonId, startDate, endDate, ct);
|
||||
var rows = new List<NamedUntisAbsenceRowDto>();
|
||||
foreach (var absence in absences)
|
||||
{
|
||||
var match = absence.ExternKey is { } key && byExternKey.TryGetValue(key, out var byKeyStudent)
|
||||
? byKeyStudent
|
||||
: byName.GetValueOrDefault(NameKey(absence.StudentName));
|
||||
if (match is null) continue; // nur die explizit freigegebenen Schüler, nie "geraten"
|
||||
if (!TryParseDate(absence.Date, out var date)) continue;
|
||||
|
||||
var entry = localSessions.TryGetValue(date, out var session)
|
||||
? participation.GetBySessionAndStudent(session.Id, match.Id) : null;
|
||||
rows.Add(new NamedUntisAbsenceRowDto(
|
||||
match.Id, match.FullName, date, absence.Reason ?? "",
|
||||
LocalStatusLabel(entry?.Attendance), UntisLessonAbsenceHelper.MapStatus(absence).ToString()));
|
||||
}
|
||||
return new NamedUntisAbsenceResultDto(true, $"{rows.Count} Fehlzeile(n) für {resolvedStudents.Count} Schüler.", rows);
|
||||
}
|
||||
|
||||
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
|
||||
private static bool TryParseDate(int value, out DateOnly date) =>
|
||||
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||
|
||||
// Eigene, schlanke Beschriftung statt AttendanceDisplay (Views-/ViewModel-Bezug) - Tool-Klassen
|
||||
// unter Services/Mcp sollen nicht von ViewModel-Klassen abhängen (siehe GradeTools).
|
||||
private static string LocalStatusLabel(AttendanceStatus? s) => s switch
|
||||
{
|
||||
null => "kein Status erfasst",
|
||||
AttendanceStatus.Present => "Anwesend",
|
||||
AttendanceStatus.ExcusePending => "Krank (Entschuldigung offen)",
|
||||
AttendanceStatus.Excused => "Krank, entschuldigt",
|
||||
AttendanceStatus.Unexcused => "Krank, unentschuldigt",
|
||||
AttendanceStatus.Late => "Verspätet",
|
||||
AttendanceStatus.LeftDuringClass => "Während des Unterrichts abgängig",
|
||||
_ => s.ToString()!,
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,9 @@ public static class UntisHubActions
|
||||
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||
App.Services.GetRequiredService<IStudentRepository>(),
|
||||
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
||||
App.Services.GetRequiredService<IParticipationRepository>())
|
||||
App.Services.GetRequiredService<IParticipationRepository>(),
|
||||
App.Services.GetRequiredService<AiPlanningService>(),
|
||||
App.Services.GetRequiredService<AiSettingsService>())
|
||||
{ StartDate = start.ToDateTime(TimeOnly.MinValue), EndDate = end.ToDateTime(TimeOnly.MinValue) };
|
||||
var loaded = TrackLoad(vm, v => v.Busy);
|
||||
await new WebUntisLessonAbsenceComparisonDialog { DataContext = vm }.ShowDialog(owner);
|
||||
|
||||
@@ -46,11 +46,23 @@ public sealed class UntisHubService(
|
||||
return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow);
|
||||
}
|
||||
|
||||
public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? summary) =>
|
||||
/// <summary>Der Langzeit-Fehlzeitenabgleich deckt seit Schuljahresbeginn ein Zeitfenster ab, das
|
||||
/// das kurzfristige vollständig einschließt - ohne das hier mitzuziehen bliebe die kurzfristige
|
||||
/// Kadenz trotz erledigtem Langzeit-Abgleich als fällig stehen (Nutzer-Feedback). Umgekehrt deckt
|
||||
/// ein Kurz-Lauf das lange Fenster nicht ab, bleibt also einseitig.
|
||||
public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? summary)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
Save(kind, groupId, now, summary);
|
||||
if (kind == UntisHubJobKind.FehlzeitenLang)
|
||||
Save(UntisHubJobKind.FehlzeitenKurz, groupId, now, "durch Abgleich seit Schuljahresbeginn mit erledigt");
|
||||
}
|
||||
|
||||
private void Save(UntisHubJobKind kind, Guid? groupId, DateTime at, string? summary) =>
|
||||
jobStates.Save(new UntisHubJobState
|
||||
{
|
||||
Id = jobStates.Get(kind, groupId)?.Id ?? Guid.NewGuid(),
|
||||
Kind = kind, GroupId = groupId, LastRunAt = DateTime.UtcNow, LastResultSummary = summary,
|
||||
Kind = kind, GroupId = groupId, LastRunAt = at, LastResultSummary = summary,
|
||||
});
|
||||
|
||||
/// Reine Entscheidungslogik ohne Repository-Zugriff (gleiches Muster wie
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using LehrerApp.Core.Importing;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Aus <c>WebUntisLessonAbsenceComparisonViewModel</c> herausgelöst (Nachtrag: die MCP-Tools in
|
||||
/// <c>UntisComparisonTools</c> brauchen exakt dieselbe Regel für dieselbe Fehlzeile, siehe TODO.md) -
|
||||
/// framework-frei (kein ObservableObject/Avalonia-Bezug), damit beide Aufrufer ohne Duplikat
|
||||
/// garantiert denselben Statusvorschlag berechnen. Eine Abweichung zwischen Dialog und MCP-Tool für
|
||||
/// dieselbe WebUntis-Zeile wäre verwirrender als die eine zusätzliche Indirektion hier.
|
||||
/// </summary>
|
||||
public static class UntisLessonAbsenceHelper
|
||||
{
|
||||
private const int FullLessonMinutes = 45;
|
||||
|
||||
// Der Bericht liefert keinen Entschuldigungstext, nur Minutenwerte, ein Bearbeitet-Datum und die
|
||||
// (laut Schule) über Klammerung der ENr codierte Entscheidung des Klassenlehrers - ENr in
|
||||
// Klammern bedeutet unentschuldigt, ohne Klammern abgeschlossen/entschuldigt. Reihenfolge ist
|
||||
// wichtig: "nach Hause entlassen" zählt immer als vorzeitige Entlassung, unabhängig von der
|
||||
// Dauer; darunter zählt jede Fehlzeit unter einer vollen Stunde (45 Min.) immer als Verspätung
|
||||
// oder sonstiger Teilverlust, nie als komplette Abwesenheit - der Text "Verspätung" allein ist
|
||||
// laut Schule nicht zuverlässig genug, deshalb primär über die Minutenschwelle erkannt.
|
||||
public static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence)
|
||||
{
|
||||
if (IsEarlyRelease(absence)) return AttendanceStatus.LeftDuringClass;
|
||||
if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.Late;
|
||||
if (string.IsNullOrWhiteSpace(absence.HandledOn)) return AttendanceStatus.ExcusePending;
|
||||
if (absence.ExternKey is null) return AttendanceStatus.ExcusePending;
|
||||
return absence.ExternKeyInParentheses ? AttendanceStatus.Unexcused : AttendanceStatus.Excused;
|
||||
}
|
||||
|
||||
private static bool IsEarlyRelease(UntisLessonAbsenceDto absence) =>
|
||||
absence.Reason?.Contains("entlassen", StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
/// Erste Wahl zum Zuordnen einer WebUntis-Fehlzeile: die externe Schülernummer (ENr), sofern der
|
||||
/// Schüler eine hat (z.B. nicht bei manuell statt per WebUntis-Import angelegten Schülern).
|
||||
public static int? StudentExternKey(Student student)
|
||||
{
|
||||
student.ExternalIds ??= [];
|
||||
return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
|
||||
&& int.TryParse(value, out var key) ? key : null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LehrerApp.Core.Importing;
|
||||
using LehrerApp.Core.AiPlanning;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
@@ -24,10 +24,38 @@ public partial class WebUntisLessonAbsenceRow : ObservableObject
|
||||
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
||||
public bool CanApply => SessionId is not null;
|
||||
|
||||
// Nur für den optionalen KI-Statusvorschlag mitgeführt (siehe
|
||||
// WebUntisLessonAbsenceComparisonViewModel.SuggestStatusWithAi) - dieselben Rohsignale, aus
|
||||
// denen MapStatus den TargetStatus berechnet, bewusst OHNE Name/Klasse/Datum, damit die Anfrage
|
||||
// an das KI-Backend personenbezogen leer bleibt.
|
||||
internal int AbsentMinutes { get; init; }
|
||||
internal bool HandledOn { get; init; }
|
||||
internal bool? ExternKeyInParentheses { get; init; }
|
||||
|
||||
// Statusübernahme ist per ComboBox anpassbar (Nutzer-Feedback: der aus WebUntis abgeleitete
|
||||
// TargetStatus war über den reinen Anzeigetext oft nicht eindeutig nachvollziehbar; Ablehnen der
|
||||
// ganzen Zeile und der Status manuell nachtragen war die einzige Korrekturmöglichkeit) - die
|
||||
// ComboBox ist mit TargetStatus vorbelegt, aber vor "Übernehmen" frei änderbar. Bindet wie bei
|
||||
// GradeCategoryDisplay über einen String-Wrapper statt direkt ans Enum (sonst ToString() auf
|
||||
// Englisch). Nur die Stati, die MapStatus tatsächlich liefert bzw. die als Korrektur plausibel
|
||||
// sind (nicht z.B. "Geschwänzt" oder "Suspendiert", die WebUntis hier nie meldet). Internal statt
|
||||
// private, damit SuggestStatusWithAi eine von der KI zurückgegebene Statusangabe dagegen validieren
|
||||
// kann, statt jeden von der KI genannten Enum-Namen blind zu übernehmen.
|
||||
internal static readonly AttendanceStatus[] SelectableStatuses =
|
||||
[
|
||||
AttendanceStatus.ExcusePending, AttendanceStatus.Excused, AttendanceStatus.Unexcused,
|
||||
AttendanceStatus.Late, AttendanceStatus.LeftDuringClass, AttendanceStatus.Present,
|
||||
];
|
||||
public static string[] StatusOptions { get; } = SelectableStatuses.Select(s => AttendanceDisplay.Label(s)).ToArray();
|
||||
|
||||
[ObservableProperty] private Student? _assignedStudent;
|
||||
[ObservableProperty] private string _localStatus = "ohne Zuordnung";
|
||||
[ObservableProperty] private Guid? _sessionId;
|
||||
[ObservableProperty] private bool _selected;
|
||||
[ObservableProperty] private string _selectedStatusName = "";
|
||||
|
||||
public AttendanceStatus SelectedStatus =>
|
||||
SelectableStatuses.FirstOrDefault(s => AttendanceDisplay.Label(s) == SelectedStatusName, TargetStatus);
|
||||
|
||||
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
|
||||
}
|
||||
@@ -42,6 +70,8 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IParticipationSessionRepository _sessions;
|
||||
private readonly IParticipationRepository _participation;
|
||||
private readonly AiPlanningService _ai;
|
||||
private readonly AiSettingsService _aiSettings;
|
||||
|
||||
private IReadOnlyList<Student> _loadedStudents = [];
|
||||
private IReadOnlyDictionary<DateOnly, ParticipationSession> _loadedSessions =
|
||||
@@ -52,14 +82,15 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
||||
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
||||
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
|
||||
[ObservableProperty] private bool _busy;
|
||||
[ObservableProperty] private bool _aiSuggestBusy;
|
||||
[ObservableProperty] private bool _markUnknownAsPresent;
|
||||
|
||||
public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
|
||||
IStudentRepository students, IParticipationSessionRepository sessions,
|
||||
IParticipationRepository participation)
|
||||
IParticipationRepository participation, AiPlanningService ai, AiSettingsService aiSettings)
|
||||
{
|
||||
_group = group; _untis = untis; _students = students; _sessions = sessions;
|
||||
_participation = participation;
|
||||
_participation = participation; _ai = ai; _aiSettings = aiSettings;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -88,7 +119,7 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
||||
// Erste Wahl: WebUntis-Kennung (ENr). Nicht jeder Schüler hat eine (z.B. manuell statt
|
||||
// per WebUntis-Import angelegt) - Fallback über den Namen, aber nur wenn er innerhalb
|
||||
// der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten.
|
||||
var byKey = courseStudents.Select(student => (Student: student, Key: StudentKey(student)))
|
||||
var byKey = courseStudents.Select(student => (Student: student, Key: UntisLessonAbsenceHelper.StudentExternKey(student)))
|
||||
.Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
|
||||
var byName = courseStudents
|
||||
.SelectMany(student => new[]
|
||||
@@ -125,13 +156,18 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
||||
var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent)
|
||||
? byKeyStudent
|
||||
: byName.GetValueOrDefault(NameKey(absence.StudentName));
|
||||
var targetStatus = MapStatus(absence);
|
||||
var row = new WebUntisLessonAbsenceRow
|
||||
{
|
||||
UntisStudentName = absence.StudentName, Date = date!.Value,
|
||||
TimeLabel = TimeLabel(absence.StartTime, absence.EndTime),
|
||||
UntisStatus = DisplayUntisStatus(absence),
|
||||
TargetStatus = MapStatus(absence), Reason = absence.Reason,
|
||||
TargetStatus = targetStatus, Reason = absence.Reason,
|
||||
AbsentMinutes = absence.AbsentMinutes,
|
||||
HandledOn = !string.IsNullOrWhiteSpace(absence.HandledOn),
|
||||
ExternKeyInParentheses = absence.ExternKey is null ? null : absence.ExternKeyInParentheses,
|
||||
Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch,
|
||||
SelectedStatusName = AttendanceDisplay.Label(targetStatus),
|
||||
};
|
||||
Rows.Add(row);
|
||||
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
|
||||
@@ -146,6 +182,59 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
||||
finally { Busy = false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fragt für alle geladenen, einer lokalen Kursstunde zuordenbaren Zeilen in einer gebündelten
|
||||
/// Anfrage (Kosten/Latenz, siehe AiPlanningService.RequestUntisStatusSuggestionsAsync) einen
|
||||
/// KI-Statusvorschlag ab und setzt ihn nur in der "Übernahme als"-ComboBox vor - Namen, Klasse
|
||||
/// und Datum verlassen dafür nie die App (siehe AiUntisStatusRow), nur die je Zeile rein
|
||||
/// technische Positions-Id sowie die bereits lokal bekannten Rohsignale. Ersetzt nie
|
||||
/// eigenständig einen bestehenden Übernahme-Status ohne Zutun der Lehrkraft - "Markierte
|
||||
/// übernehmen" bleibt der einzige schreibende Schritt.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private async Task SuggestStatusWithAi()
|
||||
{
|
||||
var token = _aiSettings.GetToken();
|
||||
if (token is null)
|
||||
{
|
||||
Status = "Nicht angemeldet. Bitte in den Einstellungen bei der KI-Unterstützung anmelden.";
|
||||
return;
|
||||
}
|
||||
var candidates = Rows.Where(x => x.CanApply).ToList();
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
Status = "Keine Zeilen mit lokaler Kursstunde geladen.";
|
||||
return;
|
||||
}
|
||||
|
||||
AiSuggestBusy = true;
|
||||
try
|
||||
{
|
||||
var requestRows = candidates.Select((row, i) => new AiUntisStatusRow
|
||||
{
|
||||
Id = i.ToString(), ReasonText = row.Reason ?? "", AbsentMinutes = row.AbsentMinutes,
|
||||
HandledOn = row.HandledOn, ExternKeyInParentheses = row.ExternKeyInParentheses,
|
||||
CurrentGuess = row.TargetStatus.ToString(),
|
||||
}).ToList();
|
||||
|
||||
var suggestions = await _ai.RequestUntisStatusSuggestionsAsync(requestRows, token);
|
||||
var applied = 0;
|
||||
for (var i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
if (!suggestions.TryGetValue(i.ToString(), out var statusName)) continue;
|
||||
if (!Enum.TryParse<AttendanceStatus>(statusName, out var status)) continue;
|
||||
if (!WebUntisLessonAbsenceRow.SelectableStatuses.Contains(status)) continue;
|
||||
candidates[i].SelectedStatusName = AttendanceDisplay.Label(status);
|
||||
applied++;
|
||||
}
|
||||
Status = suggestions.Count == 0
|
||||
? "Die KI hat keinen verwertbaren Vorschlag geliefert, die bisherige Vorbelegung bleibt unverändert."
|
||||
: $"KI-Vorschlag für {applied} von {candidates.Count} Zeilen in \"Übernahme als\" vorbelegt - bitte prüfen.";
|
||||
}
|
||||
catch (AiBackendException ex) { Status = ex.Message; }
|
||||
finally { AiSuggestBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Apply()
|
||||
{
|
||||
@@ -155,7 +244,7 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
||||
var studentId = row.AssignedStudent!.Id;
|
||||
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId)
|
||||
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId };
|
||||
entry.Attendance = row.TargetStatus;
|
||||
entry.Attendance = row.SelectedStatus;
|
||||
entry.UpdatedAt = DateTime.UtcNow;
|
||||
_participation.Save(entry);
|
||||
}
|
||||
@@ -192,38 +281,16 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
||||
return filled;
|
||||
}
|
||||
|
||||
private static int? StudentKey(Student student)
|
||||
{
|
||||
student.ExternalIds ??= [];
|
||||
return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
|
||||
&& int.TryParse(value, out var key) ? key : null;
|
||||
}
|
||||
|
||||
// Groß-/Kleinschreibung, Leerraum und - da die tatsächliche WebUntis-Reihenfolge nicht
|
||||
// dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen
|
||||
// werden beim Aufbau von `byName` registriert; hier wird nur normalisiert.
|
||||
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
|
||||
|
||||
private const int FullLessonMinutes = 45;
|
||||
|
||||
// Der Bericht liefert keinen Entschuldigungstext, nur Minutenwerte, ein Bearbeitet-Datum und die
|
||||
// (laut Schule) über Klammerung der ENr codierte Entscheidung des Klassenlehrers - ENr in
|
||||
// Klammern bedeutet unentschuldigt, ohne Klammern abgeschlossen/entschuldigt. Reihenfolge ist
|
||||
// wichtig: "nach Hause entlassen" zählt immer als vorzeitige Entlassung, unabhängig von der
|
||||
// Dauer; darunter zählt jede Fehlzeit unter einer vollen Stunde (45 Min.) immer als Verspätung
|
||||
// oder sonstiger Teilverlust, nie als komplette Abwesenheit - der Text "Verspätung" allein ist
|
||||
// laut Schule nicht zuverlässig genug, deshalb primär über die Minutenschwelle erkannt.
|
||||
private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence)
|
||||
{
|
||||
if (IsEarlyRelease(absence)) return AttendanceStatus.LeftDuringClass;
|
||||
if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.Late;
|
||||
if (string.IsNullOrWhiteSpace(absence.HandledOn)) return AttendanceStatus.ExcusePending;
|
||||
if (absence.ExternKey is null) return AttendanceStatus.ExcusePending;
|
||||
return absence.ExternKeyInParentheses ? AttendanceStatus.Unexcused : AttendanceStatus.Excused;
|
||||
}
|
||||
|
||||
private static bool IsEarlyRelease(UntisLessonAbsenceDto absence) =>
|
||||
absence.Reason?.Contains("entlassen", StringComparison.OrdinalIgnoreCase) == true;
|
||||
// MapStatus/StudentKey leben jetzt in UntisLessonAbsenceHelper (framework-frei), damit
|
||||
// UntisComparisonTools (MCP) exakt dieselbe Regel verwendet statt eines eigenen Duplikats, das
|
||||
// aus dem Tritt geraten könnte.
|
||||
private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence) =>
|
||||
UntisLessonAbsenceHelper.MapStatus(absence);
|
||||
|
||||
// Nutzt dieselben deutschen Bezeichnungen wie die reguläre Mitarbeitserfassung
|
||||
// (AttendanceDisplay.Label), statt eigene Statustexte zu erfinden.
|
||||
|
||||
@@ -161,7 +161,9 @@ public partial class GroupDetailView : UserControl
|
||||
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||
App.Services.GetRequiredService<IStudentRepository>(),
|
||||
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
||||
App.Services.GetRequiredService<IParticipationRepository>());
|
||||
App.Services.GetRequiredService<IParticipationRepository>(),
|
||||
App.Services.GetRequiredService<AiPlanningService>(),
|
||||
App.Services.GetRequiredService<AiSettingsService>());
|
||||
await new WebUntisLessonAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
|
||||
vm.ParticipationTab.RefreshCurrentGrid();
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisLessonAbsenceComparisonDialog"
|
||||
x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel"
|
||||
Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1000" Height="640"
|
||||
MinWidth="800" MinHeight="450" WindowStartupLocation="CenterOwner">
|
||||
Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1150" Height="640"
|
||||
MinWidth="900" MinHeight="450" WindowStartupLocation="CenterOwner">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto,Auto" Margin="24" RowSpacing="8">
|
||||
<StackPanel Grid.Row="0" Spacing="4">
|
||||
<TextBlock Text="Fehlzeiten je Unterricht mit WebUntis abgleichen" Classes="dialogtitle"/>
|
||||
<TextBlock Text="Nur markierte Zeilen mit einer vorhandenen lokalen Kursstunde werden übernommen. Zeilen ohne automatische Zuordnung bitte manuell einem Kursmitglied zuweisen."
|
||||
<TextBlock Text="Nur markierte Zeilen mit einer vorhandenen lokalen Kursstunde werden übernommen. Zeilen ohne automatische Zuordnung bitte manuell einem Kursmitglied zuweisen. „Übernahme als“ ist mit dem aus WebUntis abgeleiteten Status vorbelegt, aber vor dem Übernehmen änderbar."
|
||||
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
|
||||
@@ -17,20 +17,24 @@
|
||||
<TextBlock Text="bis" VerticalAlignment="Center"/>
|
||||
<CalendarDatePicker SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
|
||||
<Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
<Button Content="KI-Vorschlag für Übernahme laden" Command="{Binding SuggestStatusWithAiCommand}"
|
||||
IsEnabled="{Binding !AiSuggestBusy}"
|
||||
ToolTip.Tip="Schickt für jede geladene Zeile nur eine technische Positions-Id sowie Grund/Minuten/Bearbeitet-Kennzeichen ans KI-Backend - nie Namen, Klasse oder Datum. Setzt nur die "Übernahme als"-Auswahl vor, übernimmt nichts von selbst."/>
|
||||
</StackPanel>
|
||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="4,0">
|
||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,1.1*,1.1*,80,80,1.1*,1.1*,1*" ColumnSpacing="8" Margin="4,0">
|
||||
<TextBlock Grid.Column="1" Text="Name (WebUntis)" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="2" Text="Zuordnung" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="3" Text="Datum" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="4" Text="Zeit" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="5" Text="WebUntis-Status" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="6" Text="Lokaler Status" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="6" Text="Übernahme als" FontSize="11" Opacity="0.6"/>
|
||||
<TextBlock Grid.Column="7" Text="Lokaler Status" FontSize="11" Opacity="0.6"/>
|
||||
</Grid>
|
||||
<ScrollViewer Grid.Row="3">
|
||||
<ItemsControl ItemsSource="{Binding Rows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:WebUntisLessonAbsenceRow">
|
||||
<Grid ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="0,3">
|
||||
<Grid ColumnDefinitions="Auto,1.1*,1.1*,80,80,1.1*,1.1*,1*" ColumnSpacing="8" Margin="0,3">
|
||||
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding UntisStudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||
<ComboBox Grid.Column="2" ItemsSource="{Binding Candidates}" SelectedItem="{Binding AssignedStudent}"
|
||||
@@ -39,7 +43,9 @@
|
||||
<TextBlock Grid.Column="3" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="4" Text="{Binding TimeLabel}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="5" Text="{Binding UntisStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="6" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||
<ComboBox Grid.Column="6" ItemsSource="{Binding StatusOptions}" SelectedItem="{Binding SelectedStatusName}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
<TextBlock Grid.Column="7" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<ScrollViewer Grid.Row="1" MaxHeight="380" Margin="24,18">
|
||||
<TextBlock Text="{Binding Message}" TextWrapping="Wrap" FontSize="14" LineHeight="20"/>
|
||||
</ScrollViewer>
|
||||
<StackPanel Grid.Row="2" Margin="24,0,24,14" Spacing="6">
|
||||
<StackPanel Grid.Row="2" Margin="24,0,24,14" Spacing="6" IsVisible="{Binding AllowSessionTrust}">
|
||||
<CheckBox x:Name="TrustOperationCheck" FontSize="12"
|
||||
Content="Diese Art von Aktion für den Rest der Sitzung nicht mehr nachfragen"/>
|
||||
<CheckBox x:Name="TrustAllCheck" FontSize="12"
|
||||
|
||||
@@ -11,4 +11,11 @@ public class McpConfirmDialogInfo
|
||||
public string Title { get; init; } = "";
|
||||
public string Message { get; init; } = "";
|
||||
public string ConfirmText { get; init; } = "Übernehmen";
|
||||
|
||||
/// <summary>False blendet beide "nicht mehr nachfragen"-Kontrollkästchen aus (Nutzer-Entscheidung
|
||||
/// zu <c>get_named_untis_absence_pattern</c>: eine namentliche Fehlzeitenauskunft muss jedes Mal
|
||||
/// einzeln bestätigt werden) - AvaloniaMcpConfirmationService ignoriert eine Sitzungsfreigabe für
|
||||
/// diesen Aufruf ohnehin serverseitig, das Ausblenden verhindert zusätzlich die falsche
|
||||
/// Erwartung, ein angekreuztes Kästchen hätte hier irgendeine Wirkung.</summary>
|
||||
public bool AllowSessionTrust { get; init; } = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user