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>
208 lines
13 KiB
C#
208 lines
13 KiB
C#
using System.Collections.Concurrent;
|
||
using System.ComponentModel;
|
||
using LehrerApp.Core.Interfaces;
|
||
using LehrerApp.Core.Models;
|
||
|
||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||
|
||
/// <summary>
|
||
/// MCP-Tools für die WebUntis-Abgleiche (Nutzer-Nachtrag zum Untis-Hub, siehe TODO.md), zweigleisig
|
||
/// wie vom Nutzer entschieden:
|
||
///
|
||
/// - <see cref="GetUntisAbsenceRows"/>/<see cref="ApplyUntisAbsenceStatus"/> (bevorzugter Weg): eine
|
||
/// rein technische <c>row-id</c> 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 <see cref="GetUntisAbsenceRows"/>).
|
||
/// - <see cref="GetNamedUntisAbsencePattern"/> (bewusste, eng begrenzte Ausnahme von der sonst in
|
||
/// <see cref="McpToolScope"/> 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 (<c>allowSessionTrust: false</c>).
|
||
///
|
||
/// <see cref="GetUntisHubStatus"/> ergänzt beide Wege um einen Überblick, welche Abgleiche laut
|
||
/// <see cref="UntisHubService"/> überhaupt fällig sind, ohne selbst WebUntis anzufragen.
|
||
/// </summary>
|
||
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<string, PendingAbsenceRow> _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<UntisHubStatusRowDto> 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<List<UntisAbsenceRowDto>> 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<UntisAbsenceRowDto>();
|
||
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<WriteResultDto> 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<AttendanceStatus>(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<NamedUntisAbsenceResultDto> GetNamedUntisAbsencePattern(
|
||
[Description("Lerngruppen-ID.")] Guid groupId,
|
||
[Description("Ids der Schüler, für die Name UND Fehlzeiten gemeinsam offengelegt werden sollen.")] List<Guid> 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<Student>().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<NamedUntisAbsenceRowDto>();
|
||
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()!,
|
||
};
|
||
}
|