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
@@ -605,6 +605,59 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
return result;
}
/// <summary>
/// Fragt für eine Charge anonymisierter Fehlzeitenzeilen (ai-backend/untis-status.php,
/// Nutzer-Feedback zum Untis-Hub) einen Statusvorschlag ab - eine Anfrage für alle fraglichen
/// Zeilen eines Abgleichslaufs statt einer je Zeile (Kosten/Latenz-Überlegung aus der
/// Nutzerdiskussion). Liefert nur dann Vorschläge zurück, wenn die vom Backend gemeldete
/// Id-Menge exakt der gesendeten entspricht (keine fehlenden, zusätzlichen oder doppelten Ids) -
/// andernfalls eine leere Zuordnung, statt sich auf eine möglicherweise vermischte Reihenfolge
/// zu verlassen. Der Aufrufer behält für jede nicht zurückgelieferte Id den bisherigen
/// regelbasierten Status bei.
/// </summary>
public async Task<IReadOnlyDictionary<string, string>> RequestUntisStatusSuggestionsAsync(
IReadOnlyList<AiUntisStatusRow> rows, string token)
{
if (rows.Count == 0) return new Dictionary<string, string>();
using var req = new HttpRequestMessage(HttpMethod.Post, "untis-status.php")
{
Content = JsonContent.Create(new AiUntisStatusRequest { Rows = rows.ToList() }, options: JsonOptions),
};
req.Headers.Authorization = new("Bearer", token);
HttpResponseMessage resp;
try { resp = await http.SendAsync(req); }
catch (HttpRequestException)
{
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
}
if (resp.StatusCode == HttpStatusCode.Unauthorized)
throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
if (resp.StatusCode == (HttpStatusCode)402)
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
if (!resp.IsSuccessStatusCode)
throw await BuildRequestFailedExceptionAsync(resp);
AiUntisStatusResponse? result;
try { result = await resp.Content.ReadFromJsonAsync<AiUntisStatusResponse>(JsonOptions); }
catch (Exception ex) when (ex is not AiBackendException)
{
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
}
if (result is null)
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
var sentIds = rows.Select(r => r.Id).ToHashSet();
var receivedIds = result.Suggestions.Select(s => s.Id).ToList();
if (receivedIds.Count != sentIds.Count || receivedIds.Distinct().Count() != receivedIds.Count
|| !sentIds.SetEquals(receivedIds))
return new Dictionary<string, string>();
return result.Suggestions.ToDictionary(s => s.Id, s => s.Status);
}
private class LoginResult { public string Token { get; set; } = ""; }
private class BalanceResult { public decimal BalanceUsd { get; set; } }
private class BackendErrorResult