feat: Untis-Hub-Kadenzkopplung, anpassbarer Statusvorschlag + KI-/MCP-Unterstuetzung beim Fehlzeitenabgleich (Nutzer-Feedback)
CI / build-and-test (push) Canceled after 0s

- UntisHubService.RecordRun: ein abgeschlossener Langzeit-Fehlzeitenabgleich schliesst die
  kurzfristige Kadenz derselben Gruppe automatisch mit ab (nicht umgekehrt).
- Fehlzeitenabgleich-Dialog: neue "Uebernahme als"-ComboBox statt starrem Zielstatus, vorbelegt
  mit dem berechneten Vorschlag, aber frei aenderbar.
- Neuer ai-backend-Endpunkt untis-status.php + AiPlanningService.RequestUntisStatusSuggestionsAsync:
  gebuendelter, anonymisierter KI-Statusvorschlag (nur Positions-Id + Rohsignale, nie Name/Klasse/
  Datum), mit hartem Id-Mengen-Abgleich gegen Verwechslung.
- Neue MCP-Tools (UntisComparisonTools): get_untis_hub_status, get_untis_absence_rows/
  apply_untis_absence_status (anonymer Weg ueber ENr-Zuordnung) sowie
  get_named_untis_absence_pattern als bewusste, eng begrenzte Ausnahme (Name+Fehlzeiten fuer
  explizit angegebene Schueler-IDs, mit Bestaetigung ohne Sitzungsfreigabe - dafuer
  IMcpConfirmationService.ConfirmAsync um allowSessionTrust erweitert).
- MapStatus/ENr-Zuordnung aus dem ViewModel in das neue, geteilte UntisLessonAbsenceHelper
  gezogen, damit Dialog und MCP-Tool nie unterschiedliche Statusvorschlaege berechnen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 22:55:33 +02:00
co-authored by Claude Sonnet 5
parent d979a84e7b
commit bfe214ecf2
25 changed files with 900 additions and 62 deletions
@@ -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))