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 @@
+
-
+
-
+
+
-
+
-
+
+
diff --git a/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml b/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml
index 5d59a3f..18a5931 100644
--- a/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml
+++ b/LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml
@@ -16,7 +16,7 @@
-
+ False blendet beide "nicht mehr nachfragen"-Kontrollkästchen aus (Nutzer-Entscheidung
+ /// zu get_named_untis_absence_pattern: 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.
+ 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);