Gesamtbild: konfigurierbare Gewichtung, Dämpfung nach Vorgang, Rückfall-Warnung
CI / build-and-test (push) Canceled after 0s
CI / build-and-test (push) Canceled after 0s
Feste Score-Gewichte durch einen Einstellungsdialog ersetzt. Ein geschlossener Vorgang dämpft ab seinem Schließ-Datum den zu seinen Schlagwörtern passenden Score-Bereich (Anwesenheit/Verspätung/ Klassenbuch), ohne Schlagwort-Treffer vorsichtshalber alle drei — die faktische Fehlquote bleibt davon unberührt. Verschlechtert sich die Lage danach wieder spürbar, erscheint eine "erneut auffällig"-Warnung im bestehenden "Muster erkannt"-Panel. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,10 @@ namespace LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
/// ein hart codierter Hex-String aus dem ViewModel bliebe beim Wechsel unverändert stehen.</summary>
|
||||
public enum ClassTeacherStatusKind { Ok, Info, Warning, Danger }
|
||||
|
||||
/// <summary>Die drei Auffälligkeits-Achsen, die ein geschlossener <see cref="Vorgang"/> per
|
||||
/// Schlagwort gezielt dämpfen kann, siehe <see cref="ClassTeacherRosterRow.MatchDomains"/>.</summary>
|
||||
public enum VorgangScoreDomain { Attendance, Lateness, Classbook }
|
||||
|
||||
public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, bool HasAbsenceToday,
|
||||
string? AbsenceTooltip, bool HasRecentClassRegisterEntry)
|
||||
{
|
||||
@@ -130,22 +134,6 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
(HasSuspensionEntry ? " · Suspendierung vermerkt" : "") +
|
||||
(HolidayWeekdaysExcluded > 0 ? $" · {HolidayWeekdaysExcluded} Ferientage abgezogen" : "");
|
||||
|
||||
// Feste Gewichte statt einstellbarer Werte (Nutzer-Entscheidung): unentschuldigt fällt am
|
||||
// stärksten ins Gewicht ("geschwänzt"), entschuldigte Abwesenheit mittel, reine Verspätung
|
||||
// ("verpennt") am wenigsten — genau die vom Nutzer gewünschte Trennung, nur als Zahl statt als
|
||||
// Statustext. Bei Bedarf später anpassbar, siehe TODO.md.
|
||||
private const double UnexcusedDayWeight = 3.0;
|
||||
private const double ExcusedDayWeight = 1.0;
|
||||
private const double LateDayWeight = 0.5;
|
||||
// Klassenbuch-Einträge anderer Lehrkräfte (Nutzer-Feedback): ein einzelner "Negativ"-Eintrag
|
||||
// wiegt ungefähr wie eine entschuldigte Fehlzeit — erst die Häufung ("durch die Decke gehen")
|
||||
// treibt den Score merklich. Die beiden genannten Muster (fehlende Hausaufgaben/schlechte
|
||||
// Mitarbeit) zählen zusätzlich etwas stärker, weil sie explizit als wiederkehrend relevant
|
||||
// genannt wurden. "Suspendiert" ist bewusst ein Ausreißer-Gewicht, das den Score sofort nach
|
||||
// oben reißt, unabhängig vom sonstigen Verlauf.
|
||||
private const double NegativeClassRegisterEntryWeight = 1.0;
|
||||
private const double ConcerningKeywordBonus = 1.5;
|
||||
private const double SuspensionKeywordWeight = 15.0;
|
||||
private static readonly string[] ConcerningKeywords = ["hausaufgabe", "mitarbeit"];
|
||||
private static readonly string[] SuspensionKeywords = ["suspendier"];
|
||||
|
||||
@@ -157,12 +145,19 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
/// zusätzliche Komplexität einer Verfallskurve. Bezieht neben Fehlzeiten auch Klassenbucheinträge
|
||||
/// anderer Lehrkräfte mit ein (<see cref="ClassRegisterScoreComponent"/>) — beides sind
|
||||
/// unabhängige Auffälligkeits-Achsen (Anwesenheit vs. Verhalten/Leistung), die sich addieren
|
||||
/// statt sich gegenseitig zu verdrängen.
|
||||
public double PatternScore =>
|
||||
YearUnexcusedDayCount * UnexcusedDayWeight +
|
||||
YearExcusedDayCount * ExcusedDayWeight +
|
||||
YearLateDayCount * LateDayWeight +
|
||||
ClassRegisterScoreComponent;
|
||||
/// statt sich gegenseitig zu verdrängen. Gewichte sind einstellbar (<see cref="PatternScoreWeights"/>,
|
||||
/// Nutzer-Entscheidung nach anfänglich festen Konstanten) und die Bereiche eines geschlossenen
|
||||
/// <see cref="Vorgang"/> sind ab dessen Schließ-Datum aus dem Score ausgeklammert (<see cref="Build"/>),
|
||||
/// deshalb jetzt ein zur Build-Zeit berechneter Wert statt einer reinen Formel über die
|
||||
/// (unveränderten, für die Quote weiter vollständigen) Year*-Zähler.
|
||||
public double PatternScore { get; init; }
|
||||
|
||||
/// Score-Anteil, der NACH dem Schließen des zuletzt geschlossenen Vorgangs dieser/dieses
|
||||
/// Schülers*in neu hinzugekommen ist, beschränkt auf dessen eigene Themenbereiche
|
||||
/// (<see cref="MatchDomains"/>) — Grundlage für die "erneut auffällig"-Warnung. 0 ohne
|
||||
/// geschlossenen Vorgang oder ohne neue Ereignisse seitdem.
|
||||
public double RegressionScore { get; init; }
|
||||
public string? RegressionVorgangTitle { get; init; }
|
||||
|
||||
private static bool IsLateReason(string reason) =>
|
||||
reason.Contains("verspät", StringComparison.OrdinalIgnoreCase);
|
||||
@@ -175,15 +170,80 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
e.CategoryName?.Contains(k, StringComparison.OrdinalIgnoreCase) == true ||
|
||||
e.Text?.Contains(k, StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
private static double ClassRegisterEntryWeight(UntisForeignClassRegisterEventDto e)
|
||||
private static double ClassRegisterEntryWeight(UntisForeignClassRegisterEventDto e, PatternScoreWeights weights)
|
||||
{
|
||||
var weight = 0.0;
|
||||
if (IsNegativeClassRegisterEntry(e)) weight += NegativeClassRegisterEntryWeight;
|
||||
if (MatchesAnyKeyword(e, ConcerningKeywords)) weight += ConcerningKeywordBonus;
|
||||
if (MatchesAnyKeyword(e, SuspensionKeywords)) weight += SuspensionKeywordWeight;
|
||||
if (IsNegativeClassRegisterEntry(e)) weight += weights.NegativeClassRegisterEntryWeight;
|
||||
if (MatchesAnyKeyword(e, ConcerningKeywords)) weight += weights.ConcerningKeywordBonus;
|
||||
if (MatchesAnyKeyword(e, SuspensionKeywords)) weight += weights.SuspensionKeywordWeight;
|
||||
return weight;
|
||||
}
|
||||
|
||||
// ── Dämpfung nach geschlossenem Vorgang (Nutzer-Entscheidung: bereichsbezogen statt komplett) ──
|
||||
|
||||
private static readonly string[] AttendanceDomainKeywords = ["absent", "fehlzeit", "schwänz", "unentschuldigt"];
|
||||
private static readonly string[] LatenessDomainKeywords = ["versp"];
|
||||
private static readonly string[] ClassbookDomainKeywords =
|
||||
["konflikt", "eskalat", "mitarbeit", "hausaufgabe", "klassenbuch"];
|
||||
|
||||
/// Ordnet die Freitext-Schlagwörter eines Vorgangs den Score-Bereichen zu, die er dämpfen soll
|
||||
/// (gleiche Contains-Konvention wie die "Negativ"/"verspät"-Erkennung andernorts). Kein Treffer
|
||||
/// auf irgendein Schlagwort -> alle drei Bereiche, damit ein geschlossener Vorgang nie
|
||||
/// folgenlos bleibt (z.B. bei einem reinen "Elternkontakt"-Schlagwort ohne klare Zuordnung).
|
||||
public static IReadOnlySet<VorgangScoreDomain> MatchDomains(IReadOnlyList<string> tags)
|
||||
{
|
||||
var domains = new HashSet<VorgangScoreDomain>();
|
||||
if (tags.Any(t => AttendanceDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
|
||||
domains.Add(VorgangScoreDomain.Attendance);
|
||||
if (tags.Any(t => LatenessDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
|
||||
domains.Add(VorgangScoreDomain.Lateness);
|
||||
if (tags.Any(t => ClassbookDomainKeywords.Any(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))))
|
||||
domains.Add(VorgangScoreDomain.Classbook);
|
||||
if (domains.Count == 0)
|
||||
domains = [VorgangScoreDomain.Attendance, VorgangScoreDomain.Lateness, VorgangScoreDomain.Classbook];
|
||||
return domains;
|
||||
}
|
||||
|
||||
/// Pro Bereich das jüngste Schließ-Datum unter den passenden geschlossenen Vorgängen — Ereignisse
|
||||
/// vor diesem Datum zählen für diesen Bereich nicht mehr in <see cref="PatternScore"/>, die volle
|
||||
/// Historie (Year*-Zähler, Quote) bleibt davon unberührt.
|
||||
public static IReadOnlyDictionary<VorgangScoreDomain, DateOnly> ComputeDampeningCutoffs(
|
||||
IReadOnlyList<Vorgang> closedVorgaenge)
|
||||
{
|
||||
var cutoffs = new Dictionary<VorgangScoreDomain, DateOnly>();
|
||||
foreach (var v in closedVorgaenge)
|
||||
{
|
||||
if (v.ClosedAt is not { } closedAt) continue;
|
||||
var closedDate = DateOnly.FromDateTime(closedAt);
|
||||
foreach (var domain in MatchDomains(v.Tags))
|
||||
if (!cutoffs.TryGetValue(domain, out var existing) || closedDate > existing)
|
||||
cutoffs[domain] = closedDate;
|
||||
}
|
||||
return cutoffs;
|
||||
}
|
||||
|
||||
/// Gemeinsame Score-Formel für <see cref="PatternScore"/> und <see cref="RegressionScore"/>:
|
||||
/// <paramref name="cutoffFor"/> liefert je Bereich das früheste zählende Datum (Standard
|
||||
/// <see cref="DateOnly.MinValue"/> = alles zählt, <see cref="DateOnly.MaxValue"/> = Bereich
|
||||
/// bewusst ausgeklammert — so lässt sich derselbe Code für "gedämpfter Gesamt-Score" und
|
||||
/// "nur die Bereiche eines einzelnen Vorgangs, nur danach" wiederverwenden).
|
||||
private static double ComputeScore(IReadOnlyList<ClassAbsenceDaySummaryRow> yearEntries,
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto> yearRegisterEntries,
|
||||
Func<VorgangScoreDomain, DateOnly> cutoffFor, PatternScoreWeights weights)
|
||||
{
|
||||
var attendanceCutoff = cutoffFor(VorgangScoreDomain.Attendance);
|
||||
var latenessCutoff = cutoffFor(VorgangScoreDomain.Lateness);
|
||||
var classbookCutoff = cutoffFor(VorgangScoreDomain.Classbook);
|
||||
var unexcused = yearEntries.Count(r => r.IsUnexcused && r.Date >= attendanceCutoff);
|
||||
var excused = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate && r.Date >= attendanceCutoff);
|
||||
var late = yearEntries.Count(r => !r.IsUnexcused && r.IsLate && r.Date >= latenessCutoff);
|
||||
var classRegister = yearRegisterEntries
|
||||
.Where(e => TryDate(e.Date, out var d) && d >= classbookCutoff)
|
||||
.Sum(e => ClassRegisterEntryWeight(e, weights));
|
||||
return unexcused * weights.UnexcusedDayWeight + excused * weights.ExcusedDayWeight +
|
||||
late * weights.LateDayWeight + classRegister;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ClassTeacherRosterRow> Build(
|
||||
IReadOnlyList<UntisStudentRosterCacheEntry> students,
|
||||
IReadOnlyList<ClassAbsenceDaySummaryRow> todayAbsences,
|
||||
@@ -193,8 +253,11 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
int schoolDaysElapsed = 0,
|
||||
int holidayWeekdaysExcluded = 0,
|
||||
DateOnly termStart = default,
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto>? yearClassRegisterEntries = null)
|
||||
IReadOnlyList<UntisForeignClassRegisterEventDto>? yearClassRegisterEntries = null,
|
||||
PatternScoreWeights? weights = null,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<Vorgang>>? closedVorgaengeByNameKey = null)
|
||||
{
|
||||
var effectiveWeights = weights ?? new PatternScoreWeights();
|
||||
var referenceDate = today ?? todayAbsences.FirstOrDefault()?.Date ?? DateOnly.FromDateTime(DateTime.Today);
|
||||
var absenceByKey = todayAbsences.Where(a => a.ExternKey is not null)
|
||||
.GroupBy(a => a.ExternKey!.Value).ToDictionary(g => g.Key, g => g.First());
|
||||
@@ -223,6 +286,24 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
var yearEntries = (student.ExternKey is { } yearKey ? yearByKey.GetValueOrDefault(yearKey) : null)
|
||||
?? yearByName.GetValueOrDefault(nameKey) ?? [];
|
||||
var yearRegisterEntries = yearRegisterByName.GetValueOrDefault(nameKey) ?? [];
|
||||
|
||||
// Dämpfung/Rückfall (Nutzer-Entscheidung: bereichsbezogen statt kompletter Reset).
|
||||
var closedVorgaenge = closedVorgaengeByNameKey?.GetValueOrDefault(nameKey) ?? [];
|
||||
var dampeningCutoffs = ComputeDampeningCutoffs(closedVorgaenge);
|
||||
var patternScore = ComputeScore(yearEntries, yearRegisterEntries,
|
||||
d => dampeningCutoffs.GetValueOrDefault(d, DateOnly.MinValue), effectiveWeights);
|
||||
|
||||
var mostRecentClosed = closedVorgaenge.Where(v => v.ClosedAt.HasValue)
|
||||
.OrderByDescending(v => v.ClosedAt).FirstOrDefault();
|
||||
var regressionScore = 0.0;
|
||||
if (mostRecentClosed is not null)
|
||||
{
|
||||
var relevantDomains = MatchDomains(mostRecentClosed.Tags);
|
||||
var closedDate = DateOnly.FromDateTime(mostRecentClosed.ClosedAt!.Value);
|
||||
regressionScore = ComputeScore(yearEntries, yearRegisterEntries,
|
||||
d => relevantDomains.Contains(d) ? closedDate : DateOnly.MaxValue, effectiveWeights);
|
||||
}
|
||||
|
||||
return new ClassTeacherRosterRow(student.DisplayName, student.ExternKey, absence is not null,
|
||||
absence is null ? null : $"{absence.TotalAbsentPeriods} Stunde(n) — {absence.StatusLabel}",
|
||||
registerEntries.Count > 0)
|
||||
@@ -238,7 +319,10 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
|
||||
YearExcusedDayCount = yearEntries.Count(r => !r.IsUnexcused && !r.IsLate),
|
||||
YearNegativeClassRegisterCount = yearRegisterEntries.Count(IsNegativeClassRegisterEntry),
|
||||
HasSuspensionEntry = yearRegisterEntries.Any(e => MatchesAnyKeyword(e, SuspensionKeywords)),
|
||||
ClassRegisterScoreComponent = yearRegisterEntries.Sum(ClassRegisterEntryWeight),
|
||||
ClassRegisterScoreComponent = yearRegisterEntries.Sum(e => ClassRegisterEntryWeight(e, effectiveWeights)),
|
||||
PatternScore = patternScore,
|
||||
RegressionScore = regressionScore,
|
||||
RegressionVorgangTitle = mostRecentClosed?.Title,
|
||||
SchoolDaysElapsed = schoolDaysElapsed,
|
||||
HolidayWeekdaysExcluded = holidayWeekdaysExcluded,
|
||||
TermStart = termStart,
|
||||
@@ -317,6 +401,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
private readonly IWorkTaskRepository _workTasks;
|
||||
private readonly IStudentRepository _students;
|
||||
private readonly IDocumentationRepository _documentation;
|
||||
private readonly IVorgangRepository _vorgaenge;
|
||||
private readonly PatternScoreSettingsService _patternScoreSettings;
|
||||
private readonly IParticipationRepository _participation;
|
||||
private readonly IParticipationSessionRepository _participationSessions;
|
||||
private readonly AppLogger? _logger;
|
||||
@@ -405,7 +491,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
|
||||
public ClassTeacherOverviewViewModel(WebUntisSettingsService settings, WebUntisIntegrationService untis,
|
||||
UntisReportCacheService cache, SchoolYearService schoolYear, IWorkTaskRepository workTasks,
|
||||
IStudentRepository students, IDocumentationRepository documentation, IParticipationRepository participation,
|
||||
IStudentRepository students, IDocumentationRepository documentation, IVorgangRepository vorgaenge,
|
||||
PatternScoreSettingsService patternScoreSettings, IParticipationRepository participation,
|
||||
IParticipationSessionRepository participationSessions, ClassTeacherDetailsViewModel detailsTab,
|
||||
ClassTeacherCasesViewModel casesTab, AppLogger? logger = null)
|
||||
{
|
||||
@@ -416,6 +503,8 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
_workTasks = workTasks;
|
||||
_students = students;
|
||||
_documentation = documentation;
|
||||
_vorgaenge = vorgaenge;
|
||||
_patternScoreSettings = patternScoreSettings;
|
||||
_participation = participation;
|
||||
_participationSessions = participationSessions;
|
||||
_logger = logger;
|
||||
@@ -487,9 +576,21 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
var holidayWeekdaysExcluded = rawWeekdaysElapsed - schoolDaysElapsed;
|
||||
var recentRegisterEntries = classRegisterTask.Result
|
||||
.Where(e => TryDate(e.Date, out var d) && d >= sevenDayStart).ToList();
|
||||
// Geschlossene Vorgänge je Klassen-Roster-Namen (für die bereichsbezogene Score-Dämpfung/
|
||||
// Rückfall-Erkennung in Build) — gleicher Namensabgleich wie bei den übrigen Build*-Methoden
|
||||
// dieser Klasse (z.B. BuildAttendanceParticipationNotices).
|
||||
var localStudents = _students.GetAll();
|
||||
var closedVorgaengeByNameKey = studentsTask.Result
|
||||
.Select(r => (Roster: r, Student: MatchStudent(r.DisplayName, localStudents)))
|
||||
.Where(x => x.Student is not null)
|
||||
.ToDictionary(
|
||||
x => UntisNameMatching.NameKey(x.Roster.DisplayName),
|
||||
x => (IReadOnlyList<Vorgang>)_vorgaenge.GetByStudent(x.Student!.Id)
|
||||
.Where(v => v.Status == VorgangStatus.Closed).ToList());
|
||||
foreach (var row in ClassTeacherRosterRow.Build(studentsTask.Result, todayAbsences,
|
||||
recentRegisterEntries, today, absenceDaysYear, schoolDaysElapsed,
|
||||
holidayWeekdaysExcluded, termStart, classRegisterTask.Result)) Roster.Add(row);
|
||||
holidayWeekdaysExcluded, termStart, classRegisterTask.Result,
|
||||
_patternScoreSettings.Load(), closedVorgaengeByNameKey)) Roster.Add(row);
|
||||
|
||||
StudentCount = Roster.Count;
|
||||
TodayAlertCount = Roster.Count(r => r.HasAbsenceToday);
|
||||
@@ -503,6 +604,7 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
BuildPatternNotices(absenceDaysYear, sevenDayStart);
|
||||
BuildWeekdayPatternNotices(absenceDaysYear);
|
||||
BuildAttendanceParticipationNotices();
|
||||
BuildVorgangRegressionNotices();
|
||||
BuildOwnDocumentationCounts();
|
||||
BuildOpenExcuses(absenceDaysYear, today);
|
||||
LastUpdatedLabel = $"Zuletzt aktualisiert: Heute, {DateTime.Now:HH:mm}";
|
||||
@@ -835,6 +937,25 @@ public partial class ClassTeacherOverviewViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
}
|
||||
|
||||
/// Schwelle für die "erneut auffällig"-Warnung nach einem geschlossenen Vorgang (Nutzer-
|
||||
/// Entscheidung): erst ab einem spürbaren Muster, nicht schon bei der ersten Kleinigkeit nach
|
||||
/// Abschluss — ≈ 1 unentschuldigter Tag oder vergleichbar (<see cref="PatternScoreWeights.UnexcusedDayWeight"/>
|
||||
/// Default 3,0).
|
||||
private const double RegressionThreshold = 3.0;
|
||||
|
||||
private void BuildVorgangRegressionNotices()
|
||||
{
|
||||
foreach (var row in Roster.Where(r => r.RegressionScore >= RegressionThreshold &&
|
||||
r.RegressionVorgangTitle is not null))
|
||||
{
|
||||
if (PatternNotices.Any(n => n.StudentName == row.StudentName)) continue;
|
||||
PatternNotices.Add(new ClassTeacherPatternNotice(row.StudentName,
|
||||
$"Nach Bearbeitung von \"{row.RegressionVorgangTitle}\" erneut auffällig",
|
||||
ClassTeacherStatusKind.Danger, CanCreateReminder: true));
|
||||
}
|
||||
OnPropertyChanged(nameof(HasPatternNotices));
|
||||
}
|
||||
|
||||
/// Nacharbeiten-/Kritisch-Zählung für die eigene Dokumentation der Klasse — bewusst nicht auf
|
||||
/// die letzten 7 Tage begrenzt wie <see cref="RecentClassRegisterCount"/>: ein seit Wochen
|
||||
/// offener Entwurf oder ein als "Kritisch" markierter Eintrag soll nicht aus der Kennzahl
|
||||
|
||||
Reference in New Issue
Block a user