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:
@@ -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
|
||||
|
||||
@@ -40,15 +40,18 @@ public sealed class AvaloniaMcpConfirmationService(AppLogger logger) : IMcpConfi
|
||||
private bool _trustAll;
|
||||
|
||||
public async Task<bool> 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<McpConfirmDialogResult>(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)
|
||||
{
|
||||
|
||||
@@ -16,9 +16,15 @@ public interface IMcpConfirmationService
|
||||
/// per <see cref="CallerMemberNameAttribute"/> 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.</param>
|
||||
/// <param name="allowSessionTrust">False für die eine bewusste Ausnahme, bei der eine
|
||||
/// Sitzungsfreigabe nicht angeboten werden soll (Nutzer-Entscheidung zu
|
||||
/// <c>get_named_untis_absence_pattern</c>: 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.</param>
|
||||
/// <returns>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).</returns>
|
||||
Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
|
||||
[CallerMemberName] string operationKey = "");
|
||||
[CallerMemberName] string operationKey = "", bool allowSessionTrust = true);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>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<McpServerTool>();
|
||||
|
||||
@@ -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).");
|
||||
|
||||
@@ -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. <see cref="McpServerHostedService"/> 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.
|
||||
/// </summary>
|
||||
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",
|
||||
];
|
||||
|
||||
/// <summary>Write-Tools (Phase 2+3) — jeder Aufruf läuft über <see cref="IMcpConfirmationService"/>,
|
||||
@@ -53,6 +62,7 @@ public static class McpToolScope
|
||||
"add_competency_item",
|
||||
"update_competency_item",
|
||||
"remove_competency_item",
|
||||
"apply_untis_absence_status",
|
||||
];
|
||||
|
||||
/// <summary>Löschende Write-Tools — ursprünglich eine bewusste, gezielte Ausnahme von der sonst
|
||||
|
||||
@@ -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<PlaceholderInfoDto> Placeholders);
|
||||
|
||||
public record LetterRenderResultDto(bool Success, string Message, string? Base64Pdf, string? SuggestedFileName);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public record UntisHubStatusRowDto(
|
||||
string Kind, string GroupName, string DueState, string DueLabel,
|
||||
DateTime? LastRunAt, string? LastResultSummary);
|
||||
|
||||
/// <summary>Anonymisierte Fehlzeiten-Diskrepanz (siehe UntisComparisonTools.GetUntisAbsenceRows):
|
||||
/// bewusst KEIN Schülername/keine Klasse - <see cref="RowId"/> ist die einzige Kennung, über die
|
||||
/// UntisComparisonTools.ApplyUntisAbsenceStatus später zurückordnet.</summary>
|
||||
public record UntisAbsenceRowDto(
|
||||
string RowId, DateOnly Date, string ReasonText, int AbsentMinutes, bool HandledOn,
|
||||
bool? ExternKeyInParentheses, string CurrentLocalStatus, string CurrentGuessStatus);
|
||||
|
||||
/// <summary>Eine Zeile aus dem namentlichen Ausnahmeweg (get_named_untis_absence_pattern) - im
|
||||
/// Unterschied zu <see cref="UntisAbsenceRowDto"/> 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.</summary>
|
||||
public record NamedUntisAbsenceRowDto(
|
||||
Guid StudentId, string StudentFullName, DateOnly Date, string ReasonText,
|
||||
string CurrentLocalStatus, string CurrentGuessStatus);
|
||||
|
||||
/// <summary><see cref="Granted"/> ist false bei Ablehnung/Timeout oder wenn keine der angegebenen
|
||||
/// Schüler-IDs bekannt war - <see cref="Rows"/> ist dann null, nicht nur leer, damit ein KI-Client
|
||||
/// "abgelehnt" nicht mit "keine Fehlzeiten gefunden" verwechselt.</summary>
|
||||
public record NamedUntisAbsenceResultDto(bool Granted, string Message, List<NamedUntisAbsenceRowDto>? Rows);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <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()!,
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,9 @@ public static class UntisHubActions
|
||||
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||
App.Services.GetRequiredService<IStudentRepository>(),
|
||||
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
||||
App.Services.GetRequiredService<IParticipationRepository>())
|
||||
App.Services.GetRequiredService<IParticipationRepository>(),
|
||||
App.Services.GetRequiredService<AiPlanningService>(),
|
||||
App.Services.GetRequiredService<AiSettingsService>())
|
||||
{ StartDate = start.ToDateTime(TimeOnly.MinValue), EndDate = end.ToDateTime(TimeOnly.MinValue) };
|
||||
var loaded = TrackLoad(vm, v => v.Busy);
|
||||
await new WebUntisLessonAbsenceComparisonDialog { DataContext = vm }.ShowDialog(owner);
|
||||
|
||||
@@ -46,11 +46,23 @@ public sealed class UntisHubService(
|
||||
return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow);
|
||||
}
|
||||
|
||||
public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? summary) =>
|
||||
/// <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
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using LehrerApp.Core.Importing;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Aus <c>WebUntisLessonAbsenceComparisonViewModel</c> herausgelöst (Nachtrag: die MCP-Tools in
|
||||
/// <c>UntisComparisonTools</c> 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user