using System.Collections.Concurrent; using System.ComponentModel; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; namespace LehrerApp.Desktop.Services.Mcp.Tools; /// /// MCP-Tools für die WebUntis-Abgleiche (Nutzer-Nachtrag zum Untis-Hub, siehe TODO.md), zweigleisig /// wie vom Nutzer entschieden: /// /// - / (bevorzugter Weg): eine /// rein technische row-id 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 ). /// - (bewusste, eng begrenzte Ausnahme von der sonst in /// 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 (allowSessionTrust: false). /// /// ergänzt beide Wege um einen Überblick, welche Abgleiche laut /// überhaupt fällig sind, ohne selbst WebUntis anzufragen. /// 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 _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 GetUntisHubStatus() => hub.GetRows().Select(r => new UntisHubStatusRowDto( r.Kind.ToString(), r.GroupId, 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> 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(); 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 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(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 GetNamedUntisAbsencePattern( [Description("Lerngruppen-ID.")] Guid groupId, [Description("Ids der Schüler, für die Name UND Fehlzeiten gemeinsam offengelegt werden sollen.")] List 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().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(); 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()!, }; }