diff --git a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs index 74873ae..1b361c5 100644 --- a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs +++ b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs @@ -178,3 +178,54 @@ public class AiSubstanceResearchResponse public string ActivityRestriction { get; set; } = ""; public string Source { get; set; } = ""; } + +/// +/// Wire-Vertrag für den Fehlzeiten-Statusvorschlag (ai-backend/untis-status.php, Nutzer-Feedback +/// zum Untis-Hub: der aus WebUntis abgeleitete Zielstatus war über den Anzeigetext oft nicht +/// nachvollziehbar). Bewusst OHNE jeden Personenbezug - ist eine +/// rein technische, für die KI bedeutungslose Kennung, über die der Desktop-Client die Antwort +/// zurückordnet; kein Name, keine Klasse, kein Datum verlässt damit die App. Mehrere Zeilen eines +/// Abgleichslaufs werden in einer Anfrage gebündelt statt je Zeile einzeln (Kosten/Latenz). +/// +public class AiUntisStatusRequest +{ + public List Rows { get; set; } = []; +} + +public class AiUntisStatusRow +{ + public string Id { get; set; } = ""; + public string ReasonText { get; set; } = ""; + public int AbsentMinutes { get; set; } + /// True, wenn WebUntis für den Eintrag bereits ein Bearbeitungsdatum führt (unabhängig vom + /// tatsächlichen Datum, das aus Datenschutzgründen nicht mitgeschickt wird). + public bool HandledOn { get; set; } + /// Schulinterne Konvention: Klammerung der Entschuldigungsnummer bedeutet unentschuldigt, ohne + /// Klammern entschuldigt; null, wenn keine Nummer hinterlegt ist (siehe UntisDiffService-Analog + /// in WebUntisLessonAbsenceComparisonViewModel.MapStatus). + public bool? ExternKeyInParentheses { get; set; } + /// Bereits regelbasiert ermittelter Status (siehe MapStatus) - der Systemprompt bittet die KI, + /// nur bei eindeutigem Widerspruch im Freitext davon abzuweichen, statt bei Unsicherheit zu raten. + public string CurrentGuess { get; set; } = ""; +} + +/// +/// Enthält absichtlich GENAU eine Zeile je gesendeter - der +/// Client verwirft die gesamte Antwort, wenn die zurückgegebene Id-Menge nicht exakt der +/// gesendeten entspricht (siehe AiPlanningService.RequestUntisStatusSuggestionsAsync), statt sich +/// auf die Reihenfolge zu verlassen. So bleibt eine Verwechslung zwischen Vorschlag und Zeile +/// strukturell ausgeschlossen, nicht nur im Regelfall vermieden. +/// +public class AiUntisStatusResponse +{ + public List Suggestions { get; set; } = []; +} + +public class AiUntisStatusSuggestion +{ + public string Id { get; set; } = ""; + /// Einer von "Present"/"Late"/"LeftDuringClass"/"ExcusePending"/"Excused"/"Unexcused" (siehe + /// Systemprompt in ai-backend/untis-status.php) - wird client-seitig gegen genau diese Menge + /// geprüft, bevor er als AttendanceStatus interpretiert wird. + public string Status { get; set; } = ""; +} diff --git a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs index 4fbf47b..404d7ba 100644 --- a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs +++ b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs @@ -116,6 +116,72 @@ public sealed class AiPlanningServiceTests Assert.Equal("Gerettete Stunde", Assert.Single(lessons.GetByUnit(unit.Id)).Topic); } + [Fact] + public async Task RequestUntisStatusSuggestionsAsync_PassendeIds_LiefertZuordnung() + { + const string body = """ + {"suggestions":[{"id":"0","status":"Excused"},{"id":"1","status":"Unexcused"}]} + """; + var service = BuildWithResponse(HttpStatusCode.OK, body); + var rows = new[] + { + new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" }, + new AiUntisStatusRow { Id = "1", CurrentGuess = "ExcusePending" }, + }; + + var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token"); + + Assert.Equal("Excused", suggestions["0"]); + Assert.Equal("Unexcused", suggestions["1"]); + } + + // Kernabsicherung gegen Verwechslung (Nutzer-Feedback zum Untis-Hub, siehe + // AiPlanningService.RequestUntisStatusSuggestionsAsync): weicht die zurückgegebene Id-Menge + // auch nur minimal von der gesendeten ab (hier: eine erfundene Id "2" statt "1"), wird die + // gesamte Antwort verworfen statt sich auf eine möglicherweise vermischte Zuordnung zu verlassen. + [Fact] + public async Task RequestUntisStatusSuggestionsAsync_AbweichendeIdMenge_VerwirftKomplett() + { + const string body = """ + {"suggestions":[{"id":"0","status":"Excused"},{"id":"2","status":"Unexcused"}]} + """; + var service = BuildWithResponse(HttpStatusCode.OK, body); + var rows = new[] + { + new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" }, + new AiUntisStatusRow { Id = "1", CurrentGuess = "ExcusePending" }, + }; + + var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token"); + + Assert.Empty(suggestions); + } + + [Fact] + public async Task RequestUntisStatusSuggestionsAsync_DoppelteId_VerwirftKomplett() + { + const string body = """ + {"suggestions":[{"id":"0","status":"Excused"},{"id":"0","status":"Unexcused"}]} + """; + var service = BuildWithResponse(HttpStatusCode.OK, body); + var rows = new[] { new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" } }; + + var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token"); + + Assert.Empty(suggestions); + } + + [Fact] + public async Task RequestUntisStatusSuggestionsAsync_KeineZeilen_KeinNetzwerkaufruf() + { + var service = Build(new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + + var suggestions = await service.RequestUntisStatusSuggestionsAsync([], "token"); + + Assert.Empty(suggestions); + } + private static AiPlanningService BuildWithResponse(HttpStatusCode status, string body) { var http = new HttpClient(new StaticResponseHandler(status, body)) diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 7d6489e..21a8acb 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -623,14 +623,16 @@ public class FakeMcpConfirmation : IMcpConfirmationService public int CallCount { get; private set; } public string? LastOperationKey { get; private set; } + public bool? LastAllowSessionTrust { get; private set; } public Task ConfirmAsync(string title, string message, CancellationToken ct, - [System.Runtime.CompilerServices.CallerMemberName] string operationKey = "") + [System.Runtime.CompilerServices.CallerMemberName] string operationKey = "", bool allowSessionTrust = true) { CallCount++; LastTitle = title; LastMessage = message; LastOperationKey = operationKey; + LastAllowSessionTrust = allowSessionTrust; return Task.FromResult(Response); } } diff --git a/LehrerApp.Desktop.Tests/McpToolsTests.cs b/LehrerApp.Desktop.Tests/McpToolsTests.cs index e65684d..5ebe3dc 100644 --- a/LehrerApp.Desktop.Tests/McpToolsTests.cs +++ b/LehrerApp.Desktop.Tests/McpToolsTests.cs @@ -16,7 +16,8 @@ public sealed class McpToolsTests new[] { "download_lesson_attachment", "get_competency_catalog", "get_exams", "get_grades", - "get_lesson_plans", "get_schedule", "get_students", "get_subjects", "get_time_entries", + "get_lesson_plans", "get_named_untis_absence_pattern", "get_schedule", "get_students", + "get_subjects", "get_time_entries", "get_untis_absence_rows", "get_untis_hub_status", "list_letter_templates", "render_letter", }, McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal)); @@ -29,8 +30,8 @@ public sealed class McpToolsTests new[] { "add_competency_item", "add_lesson_attachment", "add_lesson_competency", "add_lesson_phase", - "create_competency_domain", "create_grade_entry", "create_lesson", "create_subject", - "create_time_entry", "create_unit", "move_lesson", "remove_competency_item", + "apply_untis_absence_status", "create_competency_domain", "create_grade_entry", "create_lesson", + "create_subject", "create_time_entry", "create_unit", "move_lesson", "remove_competency_item", "remove_lesson_competency", "remove_lesson_phase", "update_competency_domain", "update_competency_item", "update_lesson", "update_lesson_phase", "update_student_group_assignment", "update_subject", "update_unit", diff --git a/LehrerApp.Desktop.Tests/UntisComparisonToolsTests.cs b/LehrerApp.Desktop.Tests/UntisComparisonToolsTests.cs new file mode 100644 index 0000000..d92353c --- /dev/null +++ b/LehrerApp.Desktop.Tests/UntisComparisonToolsTests.cs @@ -0,0 +1,72 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; +using LehrerApp.Desktop.Services.Mcp.Tools; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Aus Konsistenz mit den drei bestehenden WebUntis-Abgleichs-ViewModels bewusst ohne Tests für +/// GetUntisAbsenceRows/GetNamedUntisAbsencePattern gelassen (siehe TODO.md) - beide rufen +/// WebUntisIntegrationService.GetLessonAbsencesAsync auf, was einen echten WebUntis-JSON-RPC- +/// Handshake voraussetzt. Getestet werden die Teile, die keinen WebUntis-Zugriff brauchen: +/// GetUntisHubStatus (reine Delegation an UntisHubService) und die Validierungspfade von +/// ApplyUntisAbsenceStatus, die vor jedem WebUntis-/Datenbankzugriff greifen. +public sealed class UntisComparisonToolsTests +{ + private static UntisComparisonTools Build( + List? groups = null, List? students = null, + List? sessions = null, FakeMcpConfirmation? confirmation = null) => + new(new FakeGroups(groups ?? []), new FakeStudents(students ?? []), new FakeSessions(sessions ?? []), + new FakeEntries(), TestSupport.BuildWebUntisIntegrationService(), + TestSupport.BuildUntisHubService(groups ?? []), confirmation ?? new FakeMcpConfirmation()); + + [Fact] + public void GetUntisHubStatus_DelegiertAnUntisHubServiceUndMapptKindUndDueStateAlsText() + { + var group = new LearningGroup { Name = "9a", WebUntisLessonId = 42 }; + var tool = Build([group]); + + var rows = tool.GetUntisHubStatus(); + + Assert.Contains(rows, r => r.Kind == nameof(UntisHubJobKind.FehlzeitenKurz) && r.GroupName == "9a" + && r.DueState == nameof(UntisHubDueState.Overdue)); + Assert.Contains(rows, r => r.Kind == nameof(UntisHubJobKind.OffenePeriods) && r.GroupName == "Offene Stunden"); + } + + [Fact] + public async Task ApplyUntisAbsenceStatus_UnbekannterStatus_WirdOhneRowIdPruefungAbgelehnt() + { + var tool = Build(); + + var result = await tool.ApplyUntisAbsenceStatus("irgendeine-id", "Suspendiert"); + + Assert.False(result.Applied); + Assert.Contains("Status", result.Message); + } + + [Fact] + public async Task ApplyUntisAbsenceStatus_NichtSelektierbarerAberGueltigerEnumWert_WirdAbgelehnt() + { + // "Truant"/"Suspended" etc. sind gültige AttendanceStatus-Werte, aber keine, die WebUntis + // hier je melden würde (siehe SelectableStatuses) - müssen trotzdem abgelehnt werden, damit + // ein KI-Client nicht versehentlich einen fachlich unpassenden Status setzen kann. + var tool = Build(); + + var result = await tool.ApplyUntisAbsenceStatus("irgendeine-id", nameof(AttendanceStatus.Truant)); + + Assert.False(result.Applied); + } + + [Fact] + public async Task ApplyUntisAbsenceStatus_UnbekannteRowId_WirdAbgelehntOhneBestaetigung() + { + var confirmation = new FakeMcpConfirmation(); + var tool = Build(confirmation: confirmation); + + var result = await tool.ApplyUntisAbsenceStatus("nie-vergeben", nameof(AttendanceStatus.Excused)); + + Assert.False(result.Applied); + Assert.Contains("row-id", result.Message); + Assert.Equal(0, confirmation.CallCount); // erst gar nicht bis zur Bestätigung vorgedrungen + } +} diff --git a/LehrerApp.Desktop.Tests/UntisHubServiceTests.cs b/LehrerApp.Desktop.Tests/UntisHubServiceTests.cs index b877f7a..797aa61 100644 --- a/LehrerApp.Desktop.Tests/UntisHubServiceTests.cs +++ b/LehrerApp.Desktop.Tests/UntisHubServiceTests.cs @@ -1,4 +1,5 @@ using LehrerApp.Core.Models; +using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; using Xunit; @@ -108,4 +109,34 @@ public sealed class UntisHubServiceTests Assert.Equal(2, rows.Count(r => r.GroupId != null)); } + + [Fact] + public void RecordRun_FehlzeitenLang_SchliesstAuchFehlzeitenKurzDerselbenGruppeAb() + { + // Nutzer-Feedback: der Langzeit-Abgleich deckt das kurzfristige Zeitfenster als Teilmenge mit + // ab - ohne diese Kopplung bliebe die kurzfristige Kadenz trotz erledigtem Langzeit-Abgleich + // als fällig stehen. + var group = Group("9a", 42); + var jobStates = new FakeUntisHubJobStates(); + var hub = new UntisHubService(new FakeGroups([group]), jobStates, new SchoolYearService()); + + hub.RecordRun(UntisHubJobKind.FehlzeitenLang, group.Id, "3 Fehlzeiten übernommen"); + + var kurzState = jobStates.Get(UntisHubJobKind.FehlzeitenKurz, group.Id); + var langState = jobStates.Get(UntisHubJobKind.FehlzeitenLang, group.Id); + Assert.NotNull(kurzState?.LastRunAt); + Assert.Equal(langState!.LastRunAt, kurzState!.LastRunAt); + } + + [Fact] + public void RecordRun_FehlzeitenKurz_LaesstFehlzeitenLangUnangetastet() + { + var group = Group("9a", 42); + var jobStates = new FakeUntisHubJobStates(); + var hub = new UntisHubService(new FakeGroups([group]), jobStates, new SchoolYearService()); + + hub.RecordRun(UntisHubJobKind.FehlzeitenKurz, group.Id, "keine Abweichungen"); + + Assert.Null(jobStates.Get(UntisHubJobKind.FehlzeitenLang, group.Id)); + } } diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 34aa871..e83aed6 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -227,6 +227,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/LehrerApp.Desktop/Services/AiPlanningService.cs b/LehrerApp.Desktop/Services/AiPlanningService.cs index 91fa337..dc8427a 100644 --- a/LehrerApp.Desktop/Services/AiPlanningService.cs +++ b/LehrerApp.Desktop/Services/AiPlanningService.cs @@ -605,6 +605,59 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, return result; } + /// + /// 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. + /// + public async Task> RequestUntisStatusSuggestionsAsync( + IReadOnlyList rows, string token) + { + if (rows.Count == 0) return new Dictionary(); + + 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(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(); + + 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 diff --git a/LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs b/LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs index 5366ed8..4815ad4 100644 --- a/LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs +++ b/LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs @@ -40,15 +40,18 @@ public sealed class AvaloniaMcpConfirmationService(AppLogger logger) : IMcpConfi private bool _trustAll; public async Task 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(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) { diff --git a/LehrerApp.Desktop/Services/Mcp/IMcpConfirmationService.cs b/LehrerApp.Desktop/Services/Mcp/IMcpConfirmationService.cs index c0e864f..4f23ccb 100644 --- a/LehrerApp.Desktop/Services/Mcp/IMcpConfirmationService.cs +++ b/LehrerApp.Desktop/Services/Mcp/IMcpConfirmationService.cs @@ -16,9 +16,15 @@ public interface IMcpConfirmationService /// per 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. + /// False für die eine bewusste Ausnahme, bei der eine + /// Sitzungsfreigabe nicht angeboten werden soll (Nutzer-Entscheidung zu + /// get_named_untis_absence_pattern: 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. /// 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). Task ConfirmAsync(string title, string message, CancellationToken ct, - [CallerMemberName] string operationKey = ""); + [CallerMemberName] string operationKey = "", bool allowSessionTrust = true); } diff --git a/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs b/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs index b639713..2e3f136 100644 --- a/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs +++ b/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs @@ -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); } /// 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(); @@ -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)."); diff --git a/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs b/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs index b866324..806f7be 100644 --- a/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs +++ b/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs @@ -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. 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. /// 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", ]; /// Write-Tools (Phase 2+3) — jeder Aufruf läuft über , @@ -53,6 +62,7 @@ public static class McpToolScope "add_competency_item", "update_competency_item", "remove_competency_item", + "apply_untis_absence_status", ]; /// Löschende Write-Tools — ursprünglich eine bewusste, gezielte Ausnahme von der sonst diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs b/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs index ff5673f..89a4bfb 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs @@ -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 Placeholders); public record LetterRenderResultDto(bool Success, string Message, string? Base64Pdf, string? SuggestedFileName); + +/// 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. +public record UntisHubStatusRowDto( + string Kind, string GroupName, string DueState, string DueLabel, + DateTime? LastRunAt, string? LastResultSummary); + +/// Anonymisierte Fehlzeiten-Diskrepanz (siehe UntisComparisonTools.GetUntisAbsenceRows): +/// bewusst KEIN Schülername/keine Klasse - ist die einzige Kennung, über die +/// UntisComparisonTools.ApplyUntisAbsenceStatus später zurückordnet. +public record UntisAbsenceRowDto( + string RowId, DateOnly Date, string ReasonText, int AbsentMinutes, bool HandledOn, + bool? ExternKeyInParentheses, string CurrentLocalStatus, string CurrentGuessStatus); + +/// Eine Zeile aus dem namentlichen Ausnahmeweg (get_named_untis_absence_pattern) - im +/// Unterschied zu 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. +public record NamedUntisAbsenceRowDto( + Guid StudentId, string StudentFullName, DateOnly Date, string ReasonText, + string CurrentLocalStatus, string CurrentGuessStatus); + +/// ist false bei Ablehnung/Timeout oder wenn keine der angegebenen +/// Schüler-IDs bekannt war - ist dann null, nicht nur leer, damit ein KI-Client +/// "abgelehnt" nicht mit "keine Fehlzeiten gefunden" verwechselt. +public record NamedUntisAbsenceResultDto(bool Granted, string Message, List? Rows); diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/UntisComparisonTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/UntisComparisonTools.cs new file mode 100644 index 0000000..6bce9d6 --- /dev/null +++ b/LehrerApp.Desktop/Services/Mcp/Tools/UntisComparisonTools.cs @@ -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; + +/// +/// 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.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()!, + }; +} diff --git a/LehrerApp.Desktop/Services/UntisHubActions.cs b/LehrerApp.Desktop/Services/UntisHubActions.cs index 8ead1f9..736ec11 100644 --- a/LehrerApp.Desktop/Services/UntisHubActions.cs +++ b/LehrerApp.Desktop/Services/UntisHubActions.cs @@ -25,7 +25,9 @@ public static class UntisHubActions App.Services.GetRequiredService(), App.Services.GetRequiredService(), App.Services.GetRequiredService(), - App.Services.GetRequiredService()) + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService()) { StartDate = start.ToDateTime(TimeOnly.MinValue), EndDate = end.ToDateTime(TimeOnly.MinValue) }; var loaded = TrackLoad(vm, v => v.Busy); await new WebUntisLessonAbsenceComparisonDialog { DataContext = vm }.ShowDialog(owner); diff --git a/LehrerApp.Desktop/Services/UntisHubService.cs b/LehrerApp.Desktop/Services/UntisHubService.cs index 0285d79..c16fda5 100644 --- a/LehrerApp.Desktop/Services/UntisHubService.cs +++ b/LehrerApp.Desktop/Services/UntisHubService.cs @@ -46,11 +46,23 @@ public sealed class UntisHubService( return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow); } - public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? 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 diff --git a/LehrerApp.Desktop/Services/UntisLessonAbsenceHelper.cs b/LehrerApp.Desktop/Services/UntisLessonAbsenceHelper.cs new file mode 100644 index 0000000..ba2faed --- /dev/null +++ b/LehrerApp.Desktop/Services/UntisLessonAbsenceHelper.cs @@ -0,0 +1,44 @@ +using LehrerApp.Core.Importing; +using LehrerApp.Core.Models; + +namespace LehrerApp.Desktop.Services; + +/// +/// Aus WebUntisLessonAbsenceComparisonViewModel herausgelöst (Nachtrag: die MCP-Tools in +/// UntisComparisonTools 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. +/// +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; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs index 296aa0e..f490dec 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs @@ -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 _loadedStudents = []; private IReadOnlyDictionary _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; } } + /// + /// 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. + /// + [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(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. diff --git a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs index 5d6406c..4526e16 100644 --- a/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs +++ b/LehrerApp.Desktop/Views/Groups/GroupDetailView.axaml.cs @@ -161,7 +161,9 @@ public partial class GroupDetailView : UserControl App.Services.GetRequiredService(), App.Services.GetRequiredService(), App.Services.GetRequiredService(), - App.Services.GetRequiredService()); + App.Services.GetRequiredService(), + App.Services.GetRequiredService(), + App.Services.GetRequiredService()); await new WebUntisLessonAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner); vm.ParticipationTab.RefreshCurrentGrid(); } diff --git a/LehrerApp.Desktop/Views/Groups/WebUntisLessonAbsenceComparisonDialog.axaml b/LehrerApp.Desktop/Views/Groups/WebUntisLessonAbsenceComparisonDialog.axaml index a37ff06..839a16c 100644 --- a/LehrerApp.Desktop/Views/Groups/WebUntisLessonAbsenceComparisonDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/WebUntisLessonAbsenceComparisonDialog.axaml @@ -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"> - @@ -17,20 +17,24 @@ + public bool AllowSessionTrust { get; init; } = true; } diff --git a/TODO.md b/TODO.md index 3b179ab..bfd8be6 100644 --- a/TODO.md +++ b/TODO.md @@ -2023,6 +2023,69 @@ WebUntis auffallen"). Der Fehlzeitenabgleich-Button in der einzelnen Lerngruppe (`GroupDetailView`) blieb unverändert, da kontextgebunden. +**Nachtrag zum Untis-Hub (September 2026):** Nutzer-Feedback aus der ersten echten Nutzung — fast +alle Zeilen liefen anfangs noch nie, dabei fiel auf, dass ein soeben abgeschlossener Langzeit- +Fehlzeitenabgleich die kurzfristige Kadenz derselben Gruppe unverändert als fällig stehen ließ, +obwohl deren Zeitfenster (seit Schuljahresbeginn) das kurzfristige (letzte 14 Tage) als Teilmenge +vollständig enthält. +- `UntisHubService.RecordRun` schließt bei `FehlzeitenLang` jetzt automatisch auch + `FehlzeitenKurz` derselben Gruppe mit demselben Zeitstempel ab (eigener `LastResultSummary`-Text + "durch Abgleich seit Schuljahresbeginn mit erledigt"). Nicht symmetrisch: ein Kurz-Lauf deckt das + lange Fenster nicht ab und lässt `FehlzeitenLang` deshalb unangetastet. +- Im Fehlzeitenabgleich je Unterricht ([WebUntisLessonAbsenceComparisonViewModel.cs](LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs)) + war der aus dem WebUntis-Datensatz abgeleitete Zielstatus (`MapStatus`) bisher nur als Text + sichtbar und wurde 1:1 übernommen oder gar nicht — Nutzer-Feedback: der Status-String aus WebUntis + ließ oft nicht erkennen, wohin die automatische Übernahme führt, eine Zeile wurde deshalb öfter + abgelehnt und der Status danach manuell außerhalb des Dialogs nachgetragen. Spalte "Übernahme als" + ist jetzt eine ComboBox (`WebUntisLessonAbsenceRow.StatusOptions`/`SelectedStatusName`, gleiches + String-Wrapper-Muster wie `GradeCategoryDisplay`), vorbelegt mit dem berechneten Zielstatus, aber + vor dem Übernehmen frei auf einen von sechs plausiblen Status änderbar (die WebUntis hier nie + meldet — "Geschwänzt", "Suspendiert" usw. — bleiben bewusst außen vor). +- KI-gestützter Statusvorschlag über das bestehende ai-backend (bisher nur für die + Unterrichtsplanung genutzt) ergänzt: neuer Endpunkt `ai-backend/untis-status.php` (gleiches + Auth-/Abrechnungsmuster wie `explain.php`) und `AiPlanningService.RequestUntisStatusSuggestionsAsync`. + Bewusst ohne jeden Personenbezug im Payload (Nutzer-Vorgabe: "keimfrei", solange kein Name + mitgeschickt wird) — jede Zeile bekommt nur eine rein technische Positions-Id sowie die + strukturierten Rohsignale (Fehlminuten, "bereits bearbeitet"-Kennzeichen, ENr-Klammerung) und den + Freitext-Grund, nie Name/Klasse/Datum. Alle fraglichen Zeilen eines Abgleichslaufs gehen in + **einer** gebündelten Anfrage raus statt je Zeile einzeln (Kosten/Latenz-Überlegung aus der + Nutzerdiskussion — der Fix-Overhead pro Anfrage dominiert sonst die kurze Nutzlast). Gegen + Verwechslung (Nutzer-Sorge: "die KI könnte was vermischen") wird nicht auf Reihenfolge vertraut, + sondern hart auf Id-Mengengleichheit geprüft — weicht die zurückgegebene Id-Menge auch nur + minimal ab (fehlend/zusätzlich/doppelt), wird die gesamte Antwort verworfen, siehe die vier neuen + Tests in `AiPlanningServiceTests.cs`. Der Vorschlag setzt nur die "Übernahme als"-ComboBox vor + (neuer Button "KI-Vorschlag für Übernahme laden" im Fehlzeitenabgleich), übernimmt nie + eigenständig etwas — "Markierte übernehmen" bleibt der einzige schreibende Schritt. +- MCP-Zugriff von außen auf die Untis-Abgleiche umgesetzt, zweigleisig wie vom Nutzer entschieden — + neue Klasse [UntisComparisonTools.cs](LehrerApp.Desktop/Services/Mcp/Tools/UntisComparisonTools.cs): + - `get_untis_hub_status` (Read): reine Delegation an `UntisHubService.GetRows()`, kein eigener + WebUntis-Zugriff. + - `get_untis_absence_rows`/`apply_untis_absence_status` (bevorzugter, anonymer Weg): eine rein + technische `row-id` (In-Memory, pro Prozesslaufzeit) ordnet zurück, nie ein Schülername verlässt + die App. Nur Zeilen, die WebUntis über die externe Schülernummer (ENr) eindeutig einem + Kursmitglied zuordnet, werden überhaupt gelistet — die namensbasierte Fallback-Zuordnung des + interaktiven Dialogs bleibt dafür bewusst außen vor (eine autonome KI-Schreibaktion ohne Mensch, + der eine Namensauflösung visuell gegenprüft, wäre sonst riskanter als im Dialog). Das schreibende + Tool bestätigt wie jedes andere Write-Tool, die Bestätigungsmeldung nennt aber bewusst nur Datum/ + Lerngruppe/Zielstatus, nie den Namen. + - `get_named_untis_absence_pattern` (bewusste, eng begrenzte Ausnahme von der sonst in + `McpToolScope.cs` beschriebenen Regel): verknüpft Name UND Fehlzeiten für explizit angegebene + Schüler-IDs, z.B. für einen Fehlmuster-Vergleich zwischen zwei Schülern — Datensparsamkeit über + den Aufrufparameter (nur die angefragten IDs, nicht der ganze Kurs) statt einer Auswahl-UI, um + keinen weiteren Dialogtyp einzuführen. Erfordert JEDES MAL eine gesonderte Bestätigung ohne + Sitzungsfreigabe (neuer Parameter `allowSessionTrust` auf `IMcpConfirmationService.ConfirmAsync`, + Standard weiterhin `true` für alle bestehenden Write-Tools) — `AvaloniaMcpConfirmationService` + prüft und setzt die Sitzungsfreigabe dafür gar nicht erst, `McpConfirmDialog` blendet die beiden + "nicht mehr nachfragen"-Kontrollkästchen für diesen Fall aus. + - `MapStatus`/die ENr-Zuordnung wurden aus `WebUntisLessonAbsenceComparisonViewModel` in das neue, + framework-freie `UntisLessonAbsenceHelper` (LehrerApp.Desktop/Services/) verschoben, damit Dialog + und MCP-Tool niemals unterschiedliche Statusvorschläge für dieselbe Fehlzeile berechnen. + - Bewusst ohne Tests für `GetUntisAbsenceRows`/`GetNamedUntisAbsencePattern` gelassen (gleiche + Begründung wie bei den drei bestehenden Abgleichs-ViewModels: brauchen einen echten WebUntis- + JSON-RPC-Zugriff) — `GetUntisHubStatus` und die Validierungspfade von `ApplyUntisAbsenceStatus` + (unbekannter/nicht zulässiger Status, unbekannte/abgelaufene row-id, jeweils ohne Bestätigung + erreicht) sind in `UntisComparisonToolsTests.cs` abgedeckt. + ### 4.4 Wochen-/Tagesansicht - [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3 ("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl. diff --git a/ai-backend/README.md b/ai-backend/README.md index 574079f..e4c232a 100644 --- a/ai-backend/README.md +++ b/ai-backend/README.md @@ -188,6 +188,14 @@ deaktiviert (`AllowOverride None`), oder es läuft nginx statt Apache (dort gilt grundsätzlich nicht) — dann hilft nur eine serverseitige Konfiguration durch den Hoster/Support (z.B. bei nginx ein `fastcgi_param HTTP_AUTHORIZATION $http_authorization;`). +## Update für bereits deployte Installationen (Fehlzeiten-Statusvorschlag, `untis-status.php`) + +Kein neues DB-Schema nötig (nutzt dieselben `users`/`tokens`/`transactions`-Tabellen und dieselbe +`ai_backend_call_and_charge`-Abrechnung wie `plan.php`/`explain.php`/`gbu.php`). Einfach die neue +Datei `untis-status.php` hochladen. Bewusst ohne jeden Personenbezug im Payload (Nutzer-Vorgabe zum +Untis-Hub) — die Zeilen enthalten nur eine rein technische Positions-Id sowie Fehlminuten/ +Bearbeitet-Kennzeichen/ENr-Klammerung und den Freitext-Grund, nie Name, Klasse oder Datum. + ## Fehlerbehebung: KI-Antwort endet mitten im JSON Eine im Rettungsdialog am Ende abgeschnittene Modellantwort entsteht typischerweise nicht durch @@ -211,6 +219,12 @@ werden muss. - Ob Prompt Caching tatsächlich greift (`cache_read_input_tokens` > 0 bei einer zweiten Anfrage innerhalb von 5 Minuten) — der `FakeProvider` simuliert kein Caching, das lässt sich nur gegen die echte Anthropic-API beobachten (z.B. per Blick in die `transactions`-Tabelle). +- **`untis-status.php` (Fehlzeiten-Statusvorschlag):** dieselbe Einschränkung wie oben (echtes JSON + im erwarteten Schema, korrekte Kostenberechnung) gilt hier ebenso, zusätzlich ungeprüft: ob das + Modell die vorgegebene, feste Statusliste zuverlässig einhält statt eigene Werte zu erfinden + (der Desktop-Client verwirft einen nicht in dieser Liste enthaltenen Wert defensiv, siehe + `WebUntisLessonAbsenceRow.SelectableStatuses`), sowie ob die Id-Rückgabetreue in der Praxis so + zuverlässig ist wie in den Tests angenommen (dort nur mit statischen Antworten simuliert). - **`substance.php`/`stoffliste.php` (Chemikalien-Recherche):** braucht keinen echten API-Key und keine Netzwerkverbindung mehr, ist also grundsätzlich einfacher zu verifizieren als der Rest dieser Liste — trotzdem NICHT in einer echten PHP-Umgebung gelaufen (kein PHP auf der diff --git a/ai-backend/untis-status.php b/ai-backend/untis-status.php new file mode 100644 index 0000000..dc747c5 --- /dev/null +++ b/ai-backend/untis-status.php @@ -0,0 +1,80 @@ +", + "reasonText": "", + "absentMinutes": , + "handledOn": , + "externKeyInParentheses": , + "currentGuess": "" + } + ] +} + +## Möglicher Zielstatus (exakt einer dieser Schlüssel je Zeile) + +- "Present": eigentlich anwesend (z.B. wenn der Freitext klar nahelegt, dass die Fehlzeit ein Irrtum war) +- "Late": Verspätung, kein vollständiges Fehlen der Stunde +- "LeftDuringClass": während des Unterrichts nach Hause entlassen/gegangen +- "ExcusePending": krank gemeldet, Entschuldigung noch offen/unbearbeitet +- "Excused": krank, entschuldigt +- "Unexcused": unentschuldigt + +## Aufgabe + +"currentGuess" kommt aus einer festen Regel über die strukturierten Felder und ist im Zweifel schon +korrekt - weiche davon nur ab, wenn "reasonText" eindeutig eine andere Einordnung nahelegt (z.B. ein +Text, der klar auf eine Klassenfahrt, einen Arzttermin mit bereits vorliegendem Attest, oder einen +erkennbaren Fehleintrag hindeutet). Bei Unsicherheit "currentGuess" unverändert übernehmen, nicht raten. + +## Antwortformat + +Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Freitext davor/danach), mit GENAU einem Eintrag je +Eingabe-"id" (dieselbe Anzahl, dieselben "id"-Werte unverändert, keine erfundenen oder ausgelassenen +ids): +{ "suggestions": [ { "id": "", "status": "" } ] } +PROMPT; + +$userContent = json_encode($body); +$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent); + +$parsed = ai_backend_decode_json_response($result['content']); +if (!is_array($parsed) || !isset($parsed['suggestions']) || !is_array($parsed['suggestions'])) { + ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.'); +} + +echo json_encode($parsed);