Files
admin c79d96c5ec
CI / build-and-test (push) Canceled after 0s
Add intelligent random student picker
2026-09-29 14:02:48 +02:00

201 lines
7.8 KiB
C#

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
public enum StudentDrawMode { FullRound, Intelligent }
public sealed record StudentDrawGroup(Guid Id, string Name);
/// <summary>
/// Steuert das eigenstaendige Ziehfenster. Die Ziehhistorie ist bewusst nur fuer die Laufzeit
/// des Fensters gueltig; es entstehen dadurch keine zusaetzlichen personenbezogenen Datensaetze.
/// </summary>
public partial class RandomStudentPickerViewModel : ObservableObject
{
public const string FullRoundLabel = "Alle einmal, dann neu";
public const string IntelligentLabel = "Intelligent ausgleichen";
private readonly IStudentRepository _students;
private readonly IGroupMembershipRepository _memberships;
private readonly IParticipationSessionRepository _sessions;
private readonly IParticipationRepository _participation;
private readonly Random _random;
private readonly List<Student> _candidates = [];
private readonly List<Guid> _roundBag = [];
private readonly Dictionary<Guid, int> _drawCounts = [];
private readonly Dictionary<Guid, ParticipationSignal> _signals = [];
private Guid? _lastStudentId;
[ObservableProperty] private StudentDrawGroup? _selectedGroup;
[ObservableProperty] private string _selectionModeName = FullRoundLabel;
[ObservableProperty] private bool _considerParticipation = true;
[ObservableProperty] private string _selectedStudentName = "Bereit?";
[ObservableProperty] private string _statusText = "Wähle in den Optionen eine Lerngruppe.";
[ObservableProperty] private bool _hasDrawnStudent;
public ObservableCollection<StudentDrawGroup> Groups { get; } = [];
public string[] SelectionModeOptions { get; } = [FullRoundLabel, IntelligentLabel];
public bool IsIntelligent => SelectionModeName == IntelligentLabel;
public bool CanDraw => SelectedGroup is not null && _candidates.Count > 0;
public RandomStudentPickerViewModel(
IStudentRepository students,
IGroupRepository groups,
IGroupMembershipRepository memberships,
IParticipationSessionRepository sessions,
IParticipationRepository participation,
Guid? initialGroupId = null,
Random? random = null)
{
_students = students;
_memberships = memberships;
_sessions = sessions;
_participation = participation;
_random = random ?? Random.Shared;
foreach (var group in groups.GetAll().OrderBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase))
Groups.Add(new StudentDrawGroup(group.Id, group.Name));
SelectedGroup = Groups.FirstOrDefault(g => g.Id == initialGroupId) ?? Groups.FirstOrDefault();
}
partial void OnSelectedGroupChanged(StudentDrawGroup? value) => ReloadGroup();
partial void OnSelectionModeNameChanged(string value)
{
OnPropertyChanged(nameof(IsIntelligent));
ResetDrawState();
}
partial void OnConsiderParticipationChanged(bool value) => ResetDrawState();
[RelayCommand(CanExecute = nameof(CanDraw))]
private void Draw()
{
var student = IsIntelligent ? DrawIntelligently() : DrawFromFullRound();
_drawCounts[student.Id] = _drawCounts.GetValueOrDefault(student.Id) + 1;
_lastStudentId = student.Id;
SelectedStudentName = student.FullName;
HasDrawnStudent = true;
UpdateStatus();
}
[RelayCommand]
private void Reset() => ResetDrawState();
private void ReloadGroup()
{
_candidates.Clear();
_signals.Clear();
if (SelectedGroup is not null)
{
var today = DateOnly.FromDateTime(DateTime.Today);
var activeIds = _memberships.GetByGroup(SelectedGroup.Id)
.Where(m => GroupMembershipService.IsActiveOn(m, today))
.Select(m => m.StudentId)
.ToHashSet();
_candidates.AddRange(_students.GetByGroup(SelectedGroup.Id)
.Where(s => s.IsActive && activeIds.Contains(s.Id))
.OrderBy(s => s.LastName, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(s => s.FirstName, StringComparer.CurrentCultureIgnoreCase));
LoadParticipationSignals(SelectedGroup.Id);
}
ResetDrawState();
OnPropertyChanged(nameof(CanDraw));
DrawCommand.NotifyCanExecuteChanged();
}
private void LoadParticipationSignals(Guid groupId)
{
var sessionIds = _sessions.GetByGroup(groupId).Select(s => s.Id).ToList();
var entries = sessionIds.SelectMany(_participation.GetBySession).ToList();
foreach (var student in _candidates)
{
var own = entries.Where(e => e.StudentId == student.Id).ToList();
var activity = own.Sum(e => e.RaisedHandCount + e.CalledOnCount);
var values = own.SelectMany(e => e.Ratings).Select(r => Math.Clamp(r.Value, -2, 2)).ToList();
_signals[student.Id] = new ParticipationSignal(
activity,
values.Count == 0 ? null : values.Average());
}
}
private Student DrawFromFullRound()
{
if (_roundBag.Count == 0)
{
_roundBag.AddRange(_candidates.Select(s => s.Id));
for (var i = _roundBag.Count - 1; i > 0; i--)
{
var swap = _random.Next(i + 1);
(_roundBag[i], _roundBag[swap]) = (_roundBag[swap], _roundBag[i]);
}
}
var id = _roundBag[^1];
_roundBag.RemoveAt(_roundBag.Count - 1);
return _candidates.Single(s => s.Id == id);
}
private Student DrawIntelligently()
{
var maxActivity = Math.Max(1, _signals.Values.Select(s => s.Activity).DefaultIfEmpty().Max());
var eligible = _candidates.Count > 1
? _candidates.Where(student => student.Id != _lastStudentId)
: _candidates;
var weighted = eligible.Select(student =>
{
var weight = 1.0 / (1.0 + 1.5 * _drawCounts.GetValueOrDefault(student.Id));
if (ConsiderParticipation && _signals.TryGetValue(student.Id, out var signal))
{
var activityRatio = signal.Activity / (double)maxActivity;
weight *= 1.65 - activityRatio; // wenig Aktive werden wahrscheinlicher gezogen
if (signal.AverageRating is { } rating)
weight *= 1.0 - Math.Clamp(rating, -2, 2) * 0.175; // starke spaeter, schwache frueher
}
return (Student: student, Weight: Math.Max(0.01, weight));
}).ToList();
var point = _random.NextDouble() * weighted.Sum(x => x.Weight);
foreach (var item in weighted)
{
point -= item.Weight;
if (point <= 0) return item.Student;
}
return weighted[^1].Student;
}
private void ResetDrawState()
{
_roundBag.Clear();
_drawCounts.Clear();
_lastStudentId = null;
HasDrawnStudent = false;
SelectedStudentName = "Bereit?";
UpdateStatus();
}
private void UpdateStatus()
{
StatusText = _candidates.Count switch
{
0 when SelectedGroup is null => "Wähle in den Optionen eine Lerngruppe.",
0 => "In dieser Lerngruppe sind heute keine aktiven Schüler.",
_ when !HasDrawnStudent => $"{_candidates.Count} Schüler stehen zur Auswahl.",
_ when !IsIntelligent && _roundBag.Count == 0 => "Alle waren dran. Der nächste Zug startet eine neue Runde.",
_ when !IsIntelligent => $"Noch {_roundBag.Count} bis zur nächsten Runde.",
_ => "Nächste Ziehung bereit.",
};
}
private sealed record ParticipationSignal(int Activity, double? AverageRating);
}