feat: Untis-Hub-Kadenzkopplung, anpassbarer Statusvorschlag + KI-/MCP-Unterstuetzung beim Fehlzeitenabgleich (Nutzer-Feedback)
CI / build-and-test (push) Canceled after 0s
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:
@@ -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<Student> _loadedStudents = [];
|
||||
private IReadOnlyDictionary<DateOnly, ParticipationSession> _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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<AttendanceStatus>(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.
|
||||
|
||||
Reference in New Issue
Block a user