feat: learning schedules (entities, rules, runner, endpoints, background service, migration Phase11cLearningAuto)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 19:09:48 +02:00
1 parent 034b2ef790
commit dcbcac5ed8
16 files changed
+4273

No files matched your search

@@ -11,6 +11,7 @@ public static class LearningEndpoints
LearningSolveEndpoints.Map(group);
LearningPhotoEndpoints.Map(group);
LearningAiGradeEndpoints.Map(group);
LearningScheduleEndpoints.Map(group);
return app;
}
}
@@ -0,0 +1,199 @@
using System.Security.Claims;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain.Audit;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Infrastructure.Audit;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Infrastructure.Settings;
using FamilyPlanner.Shared.Learning;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.Api.Features.Learning;
/// <summary>
/// Zeitpläne für automatisch erzeugte Übungsblätter (Phase 11c, ADR-0021). <b>Alle</b> Routen verlangen <c>LearningManage</c> und <c>AiUse</c> (nur Eltern;
/// Kinder und Gäste 403, anonym 401) – auch das Lesen, damit Kinder nichts über Zeitpläne erfahren. Konfiguration und Läufe landen im Audit-Log, nie Inhalte.
/// </summary>
internal static class LearningScheduleEndpoints
{
/// <summary>Obergrenze, damit ein Fehlklick keine Flut von KI-Aufrufen plant.</summary>
private const int MaxSchedules = 30;
public static void Map(RouteGroupBuilder group)
{
var schedules = group.MapGroup("/schedules").RequirePermission(Permission.LearningManage).RequirePermission(Permission.AiUse);
schedules.MapGet(string.Empty, ListAsync);
schedules.MapPost(string.Empty, CreateAsync).Validate<SaveLearningScheduleRequest>();
schedules.MapPut("/{id:guid}", UpdateAsync).Validate<SaveLearningScheduleRequest>();
schedules.MapDelete("/{id:guid}", DeleteAsync);
schedules.MapPost("/{id:guid}/run", RunAsync);
}
private static async Task<IResult> ListAsync(AppDbContext db, ISettingsProvider settings, TimeProvider time, CancellationToken ct)
{
var rows = await db.LearningSchedules.AsNoTracking().OrderBy(s => s.CreatedAt).ToListAsync(ct);
var names = await LearningAccess.DisplayNamesAsync(db, rows.Select(r => r.TargetUserId), ct);
var zone = (await settings.GetAsync(ct)).GetTimeZone();
var now = time.GetUtcNow();
return TypedResults.Ok(rows.Select(r => ToDto(r, names.GetValueOrDefault(r.TargetUserId, string.Empty), now, zone)).ToList());
}
private static async Task<IResult> CreateAsync(
SaveLearningScheduleRequest request, ClaimsPrincipal user, AppDbContext db, ISettingsProvider settings, IAuditLogger audit, TimeProvider time, CancellationToken ct)
{
if (await LearningManageEndpoints.CheckTargetAsync(db, request.TargetUserId, ct) is { } invalid)
{
return invalid;
}
if (await db.LearningSchedules.CountAsync(ct) >= MaxSchedules)
{
return EndpointHelpers.Conflict($"Es gibt schon {MaxSchedules} Zeitpläne. Bitte erst nicht mehr benötigte löschen.");
}
var now = time.GetUtcNow();
var schedule = new LearningSchedule
{
OwnerUserId = user.UserId() ?? Guid.Empty,
HandledThroughAt = now, // der heutige Termin liegt womöglich schon hinter uns: kein sofortiger Lauf nach dem Anlegen
};
Apply(schedule, request);
db.LearningSchedules.Add(schedule);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(user.ActorName(), AuditActions.LearningScheduleCreated, "LearningSchedule", schedule.Id.ToString(), null, Describe(schedule), ct);
return TypedResults.Created($"/api/learning/schedules/{schedule.Id}", await LoadDtoAsync(db, settings, time, schedule.Id, ct));
}
private static async Task<IResult> UpdateAsync(
Guid id, SaveLearningScheduleRequest request, ClaimsPrincipal user, AppDbContext db, ISettingsProvider settings, IAuditLogger audit, TimeProvider time, CancellationToken ct)
{
var schedule = await db.LearningSchedules.SingleOrDefaultAsync(s => s.Id == id, ct);
if (schedule is null)
{
return TypedResults.NotFound();
}
if (request.Version is { Length: > 0 } version && !version.AsSpan().SequenceEqual(schedule.RowVersion))
{
return TypedResults.Conflict(await LoadDtoAsync(db, settings, time, id, ct));
}
if (request.TargetUserId != schedule.TargetUserId && await LearningManageEndpoints.CheckTargetAsync(db, request.TargetUserId, ct) is { } invalid)
{
return invalid;
}
var now = time.GetUtcNow();
var timingChanged = request.Weekdays != schedule.Weekdays || request.TimeOfDay != schedule.TimeOfDay;
var reactivated = request.IsActive && !schedule.IsActive;
Apply(schedule, request);
schedule.OwnerUserId = user.UserId() ?? schedule.OwnerUserId; // wer zuletzt ändert, trägt die KI-Nutzung (Tageslimit, Rechte)
if (timingChanged || reactivated)
{
// Neue Zeiten oder Wiedereinschalten gelten ab jetzt – ein schon vergangener Termin des Tages löst nichts nachträglich aus.
schedule.HandledThroughAt = now > schedule.HandledThroughAt ? now : schedule.HandledThroughAt;
schedule.FailureCount = 0;
schedule.RetryAfter = null;
}
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
db.ChangeTracker.Clear();
return TypedResults.Conflict(await LoadDtoAsync(db, settings, time, id, ct));
}
await audit.WriteAsync(user.ActorName(), AuditActions.LearningScheduleUpdated, "LearningSchedule", id.ToString(), null, Describe(schedule), ct);
return TypedResults.Ok(await LoadDtoAsync(db, settings, time, id, ct));
}
private static async Task<IResult> DeleteAsync(Guid id, ClaimsPrincipal user, AppDbContext db, IAuditLogger audit, CancellationToken ct)
{
var schedule = await db.LearningSchedules.SingleOrDefaultAsync(s => s.Id == id, ct);
if (schedule is null)
{
return TypedResults.NoContent(); // idempotent
}
// Bereits erzeugte Blätter bleiben (sie gehören den Eltern und dem Kind); nur der Plan verschwindet.
db.LearningSchedules.Remove(schedule);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(user.ActorName(), AuditActions.LearningScheduleDeleted, "LearningSchedule", id.ToString(), null, Describe(schedule), ct);
return TypedResults.NoContent();
}
private static async Task<IResult> RunAsync(
Guid id, ClaimsPrincipal user, LearningScheduleRunner runner, AppDbContext db, ISettingsProvider settings, TimeProvider time, CancellationToken ct)
{
var outcome = await runner.RunAsync(id, LearningScheduleTrigger.Manual, user.UserId(), user.ActorName(), ct);
if (outcome.Result == ScheduleRunResult.NotDue && outcome.HttpStatus == StatusCodes.Status404NotFound)
{
return TypedResults.NotFound();
}
if (outcome.Result != ScheduleRunResult.Created || outcome.SetId is not { } setId)
{
return TypedResults.Problem(statusCode: outcome.HttpStatus, title: "Zeitplan konnte nicht ausgeführt werden", detail: outcome.Message);
}
db.ChangeTracker.Clear();
var dto = await LoadDtoAsync(db, settings, time, id, ct);
return TypedResults.Ok(new LearningScheduleRunResultDto(dto!, setId, outcome.Assigned, outcome.Message));
}
// ---------- Hilfen ----------
private static void Apply(LearningSchedule schedule, SaveLearningScheduleRequest request)
{
schedule.TargetUserId = request.TargetUserId;
schedule.Subject = request.Subject;
schedule.GradeLevel = request.GradeLevel;
schedule.ItemCount = request.ItemCount;
schedule.StartDifficulty = request.StartDifficulty;
schedule.RaiseEveryRuns = request.RaiseEveryRuns;
schedule.Extra = LearningAiPrompt.CleanExtra(request.Extra) is { Length: > 0 } extra ? extra : null;
schedule.Weekdays = request.Weekdays;
schedule.TimeOfDay = new TimeOnly(request.TimeOfDay.Hour, request.TimeOfDay.Minute);
schedule.AutoAssign = request.AutoAssign;
schedule.IsActive = request.IsActive;
}
/// <summary>Konfiguration als Audit-Text: Fach, Klasse, Anzahl, Modus, Zeiten – kein Freitext (<c>Extra</c>) und keine Namen.</summary>
private static string Describe(LearningSchedule s) =>
$"{s.Subject}, Klasse {s.GradeLevel}, {s.ItemCount} Aufgaben, Stufe {s.StartDifficulty}, {(s.AutoAssign ? "direkt zuweisen" : "Entwurf")}, "
+ $"{LearningScheduleRules.DescribeWeekdays(s.Weekdays)} {s.TimeOfDay:HH\\:mm}, {(s.IsActive ? "aktiv" : "pausiert")}";
private static async Task<LearningScheduleDto?> LoadDtoAsync(AppDbContext db, ISettingsProvider settings, TimeProvider time, Guid id, CancellationToken ct)
{
var schedule = await db.LearningSchedules.AsNoTracking().SingleOrDefaultAsync(s => s.Id == id, ct);
if (schedule is null)
{
return null;
}
var names = await LearningAccess.DisplayNamesAsync(db, [schedule.TargetUserId], ct);
var zone = (await settings.GetAsync(ct)).GetTimeZone();
return ToDto(schedule, names.GetValueOrDefault(schedule.TargetUserId, string.Empty), time.GetUtcNow(), zone);
}
internal static LearningScheduleDto ToDto(LearningSchedule s, string childName, DateTimeOffset now, TimeZoneInfo zone) => new(
s.Id, s.TargetUserId, childName, s.Subject, s.GradeLevel, s.ItemCount, s.StartDifficulty, s.RaiseEveryRuns,
LearningScheduleRules.DifficultyFor(s.StartDifficulty, s.RaiseEveryRuns, s.RunCount), s.Extra, s.Weekdays, s.TimeOfDay, s.AutoAssign, s.IsActive,
s.IsActive ? NextRun(s, now, zone) : null, s.RetryAfter, s.LastRunAt, s.LastStatus, s.LastMessage, s.LastSetId, s.RunCount, s.RowVersion);
/// <summary>Wann der Zeitplan als Nächstes läuft: ein schon fälliger Termin (ggf. nach der Wartezeit) oder der nächste Plantermin.</summary>
private static DateTimeOffset? NextRun(LearningSchedule s, DateTimeOffset now, TimeZoneInfo zone)
{
var latest = LearningScheduleRules.LatestSlotAtOrBefore(now, s.Weekdays, s.TimeOfDay, zone);
if (latest is { } slot && slot > s.HandledThroughAt)
{
return s.RetryAfter is { } retry && retry > now ? retry : now; // fällig: der Hintergrunddienst nimmt ihn beim nächsten Takt
}
return LearningScheduleRules.NextSlotAfter(now, s.Weekdays, s.TimeOfDay, zone);
}
}
@@ -0,0 +1,537 @@
using FamilyPlanner.Api.Features.Ai;
using FamilyPlanner.Domain.Audit;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Infrastructure.Audit;
using FamilyPlanner.Infrastructure.Identity;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Infrastructure.Settings;
using FamilyPlanner.Shared.Learning;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.Api.Features.Learning;
internal enum ScheduleRunResult
{
/// <summary>Nichts fällig (Hintergrunddienst) oder der Zeitplan ist weg/pausiert.</summary>
NotDue,
/// <summary>Ein anderer Lauf (andere Instanz, Doppelklick) hält die Sperre.</summary>
Busy,
/// <summary>Blatt erzeugt (Entwurf oder zugewiesen).</summary>
Created,
/// <summary>Übersprungen (zu viele offene Blätter, Kind/Ersteller nicht mehr gültig).</summary>
Skipped,
/// <summary>Tageslimit der KI erreicht: später erneut.</summary>
Deferred,
/// <summary>Fehlgeschlagen: mit Wartezeit erneut.</summary>
Failed,
}
internal sealed record ScheduleRunOutcome(
ScheduleRunResult Result, LearningScheduleStatus Status, string Message, int HttpStatus = StatusCodes.Status200OK, Guid? SetId = null, bool Assigned = false);
/// <summary>
/// Führt Zeitpläne der Lern-Automatik aus (Phase 11c, ADR-0021). Der Hintergrunddienst ruft <see cref="SweepAsync"/>, „Jetzt ausführen“ <see cref="RunAsync"/>.
/// <list type="bullet">
/// <item>Kein Doppellauf: eine Sperre (<see cref="LearningSchedule.LockedUntil"/>) wird atomar per <c>ExecuteUpdate</c> erworben; das Blatt, der Lauf-Eintrag und
/// <see cref="LearningSchedule.HandledThroughAt"/> werden in <b>einem</b> <c>SaveChanges</c> geschrieben, und je Plantermin lässt der eindeutige Index nur ein erzeugtes Blatt zu.</item>
/// <item>Der Kontext bleibt bis nach dem KI-Aufruf ohne vorgemerkte Änderungen (<see cref="AiService"/> schreibt sein Nutzungsprotokoll über denselben Kontext).</item>
/// <item>Die KI läuft im Namen des Erstellers; dessen Tageslimit gilt, seine Rechte werden zur Laufzeit erneut geprüft.</item>
/// <item>Protokoll und Audit enthalten nie Aufgaben, Antworten oder Fehlertexte des Anbieters.</item>
/// </list>
/// </summary>
internal sealed class LearningScheduleRunner(
AppDbContext db,
AiService ai,
LearningNotifier notifier,
IAuditLogger audit,
ISettingsProvider settingsProvider,
TimeProvider timeProvider,
ILogger<LearningScheduleRunner> logger)
{
/// <summary>Höchstens so viele Zeitpläne je Durchlauf (jeder Lauf ist ein KI-Aufruf).</summary>
private const int MaxPerSweep = 10;
public const string AutomationActor = "Lern-Automatik";
/// <summary>Alle fälligen Zeitpläne nacheinander ausführen. Liefert die Zahl erzeugter Blätter.</summary>
public async Task<int> SweepAsync(CancellationToken ct)
{
var now = timeProvider.GetUtcNow();
var zone = (await settingsProvider.GetAsync(ct)).GetTimeZone();
db.ChangeTracker.Clear();
var candidates = await db.LearningSchedules.AsNoTracking()
.Where(s => s.IsActive && (s.LockedUntil == null || s.LockedUntil <= now))
.Select(s => new { s.Id, s.OwnerUserId, s.HandledThroughAt, s.RetryAfter, s.Weekdays, s.TimeOfDay })
.ToListAsync(ct);
var due = candidates
.Select(c => (Candidate: c, Slot: LearningScheduleRules.DueSlot(now, c.HandledThroughAt, c.RetryAfter, c.Weekdays, c.TimeOfDay, zone)))
.Where(x => x.Slot is not null)
.OrderBy(x => x.Slot)
.Take(MaxPerSweep)
.ToList();
var created = 0;
var limitedOwners = new HashSet<Guid>(); // das Tageslimit gilt je Ersteller: weitere Pläne desselben Elternteils wären zwecklos
foreach (var (candidate, _) in due)
{
if (limitedOwners.Contains(candidate.OwnerUserId))
{
continue;
}
try
{
var outcome = await RunAsync(candidate.Id, LearningScheduleTrigger.Auto, actorUserId: null, actorName: null, ct);
if (outcome.Result == ScheduleRunResult.Created)
{
created++;
}
else if (outcome.Result == ScheduleRunResult.Deferred)
{
limitedOwners.Add(candidate.OwnerUserId);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Lern-Zeitplan {Schedule} konnte nicht ausgeführt werden.", candidate.Id);
}
}
return created;
}
/// <summary>
/// Führt einen Zeitplan aus. <see cref="LearningScheduleTrigger.Auto"/> nur, wenn ein Plantermin fällig ist, und im Namen des Erstellers;
/// <see cref="LearningScheduleTrigger.Manual"/> sofort, im Namen des auslösenden Elternteils, ohne einen Plantermin zu verbrauchen.
/// </summary>
public async Task<ScheduleRunOutcome> RunAsync(Guid id, LearningScheduleTrigger trigger, Guid? actorUserId, string? actorName, CancellationToken ct)
{
db.ChangeTracker.Clear();
var now = timeProvider.GetUtcNow();
var zone = (await settingsProvider.GetAsync(ct)).GetTimeZone();
var snapshot = await db.LearningSchedules.AsNoTracking().SingleOrDefaultAsync(s => s.Id == id, ct);
if (snapshot is null)
{
return new ScheduleRunOutcome(ScheduleRunResult.NotDue, LearningScheduleStatus.Never, "Den Zeitplan gibt es nicht mehr.", StatusCodes.Status404NotFound);
}
DateTimeOffset? slot = null;
if (trigger == LearningScheduleTrigger.Auto)
{
slot = snapshot.IsActive
? LearningScheduleRules.DueSlot(now, snapshot.HandledThroughAt, snapshot.RetryAfter, snapshot.Weekdays, snapshot.TimeOfDay, zone)
: null;
if (slot is null)
{
return new ScheduleRunOutcome(ScheduleRunResult.NotDue, snapshot.LastStatus, "Nichts fällig.");
}
}
// Sperre atomar erwerben; die RowVersion stellt sicher, dass wir auf dem gelesenen Stand arbeiten (eine gleichzeitige Änderung lässt den Lauf auf den nächsten Takt warten).
var version = snapshot.RowVersion;
var locked = await db.LearningSchedules
.Where(s => s.Id == id && (s.LockedUntil == null || s.LockedUntil <= now) && s.RowVersion == version)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.LockedUntil, now + LearningScheduleRules.LockDuration), ct);
if (locked != 1)
{
return new ScheduleRunOutcome(ScheduleRunResult.Busy, snapshot.LastStatus, "Dieser Zeitplan läuft gerade oder wurde eben geändert. Bitte gleich noch einmal versuchen.", StatusCodes.Status409Conflict);
}
try
{
return await ExecuteAsync(snapshot, trigger, slot, actorUserId, actorName, now, zone, ct);
}
catch
{
// Unerwartet (z. B. Datenbank, Abbruch): Sperre freigeben, damit der Zeitplan nicht zehn Minuten blockiert bleibt.
db.ChangeTracker.Clear();
await db.LearningSchedules.Where(s => s.Id == id).ExecuteUpdateAsync(s => s.SetProperty(x => x.LockedUntil, (DateTimeOffset?)null), CancellationToken.None);
throw;
}
}
private async Task<ScheduleRunOutcome> ExecuteAsync(
LearningSchedule schedule,
LearningScheduleTrigger trigger,
DateTimeOffset? slot,
Guid? actorUserId,
string? actorName,
DateTimeOffset now,
TimeZoneInfo zone,
CancellationToken ct)
{
var auto = trigger == LearningScheduleTrigger.Auto;
var difficulty = LearningScheduleRules.DifficultyFor(schedule.StartDifficulty, schedule.RaiseEveryRuns, schedule.RunCount);
var userId = actorUserId ?? schedule.OwnerUserId;
var actor = actorName ?? AutomationActor;
// ---- Vorprüfungen (kosten kein KI-Kontingent) ----
if (auto && !await OwnerMayRunAsync(schedule.OwnerUserId, now, ct))
{
return await FinishNonGenerationAsync(
schedule, trigger, slot, now, difficulty, LearningScheduleStatus.Skipped, "Der Ersteller darf die KI nicht mehr nutzen – Zeitplan pausiert.", pause: true, actor, ct);
}
if (await LearningManageEndpoints.CheckTargetAsync(db, schedule.TargetUserId, ct) is not null)
{
return await FinishNonGenerationAsync(
schedule, trigger, slot, now, difficulty, LearningScheduleStatus.Skipped, "Das Kind ist nicht mehr aktiv – Zeitplan pausiert.", pause: true, actor, ct);
}
var open = await db.LearningSets.CountAsync(
s => s.ScheduleId == schedule.Id && (s.Status == LearningSetStatus.Draft || s.Status == LearningSetStatus.Assigned), ct);
if (open >= LearningSchedule.MaxOpenSheets)
{
return await FinishNonGenerationAsync(
schedule, trigger, slot, now, difficulty, LearningScheduleStatus.Skipped,
$"Es liegen schon {open} unerledigte Blätter dieses Zeitplans. Bitte erst zuweisen, lösen lassen oder löschen.", pause: false, actor, ct);
}
// ---- KI ----
var request = new CreateAiDraftRequest(
schedule.Subject, schedule.GradeLevel, schedule.TargetUserId, schedule.ItemCount, CurveFor(difficulty), schedule.Extra, LearningMode.Practice);
DraftGeneration generation;
try
{
generation = await LearningAiDraftPipeline.GenerateAsync(ai, userId, request, ct);
}
catch (AiException ex)
{
return await FinishFailureAsync(schedule, trigger, slot, now, zone, difficulty, ex, ct);
}
if (generation.Set is not { } set)
{
return await FinishFailureAsync(
schedule, trigger, slot, now, zone, difficulty, new AiException(AiErrorKind.BadResponse, "Keine brauchbaren Aufgaben."), ct);
}
// ---- Blatt fertigstellen ----
set.ScheduleId = schedule.Id;
set.Title = TitleWithDate(set.Title, now, zone);
var assign = schedule.AutoAssign;
var status = LearningScheduleStatus.Created;
var message = assign ? "Blatt erzeugt und zugewiesen." : "Blatt als Entwurf erzeugt – bitte prüfen.";
if (assign && set.Items.Count * 2 < schedule.ItemCount)
{
// Qualitätsgrenze: bei zu wenig brauchbaren Aufgaben nie direkt an das Kind.
assign = false;
status = LearningScheduleStatus.CreatedAsDraft;
message = $"Nur {set.Items.Count} von {schedule.ItemCount} Aufgaben waren brauchbar – als Entwurf gespeichert, bitte prüfen.";
}
if (assign && LearningRules.CheckAssignable(LearningSetStatus.Draft, set.Items) is null)
{
set.Status = LearningSetStatus.Assigned;
set.AssignedAt = now;
}
else
{
assign = false;
}
var run = new LearningScheduleRun
{
SlotUtc = slot,
Trigger = trigger,
Status = status,
SetId = set.Id,
Message = message,
Difficulty = difficulty,
};
var saved = await CommitAsync(
schedule.Id,
run,
set,
s =>
{
s.RunCount++; // jedes erzeugte Blatt zählt für die Schwierigkeitssteigerung, auch ein manuell ausgelöstes
s.LastRunAt = now;
s.LastStatus = status;
s.LastMessage = message;
s.LastSetId = set.Id;
if (auto)
{
s.HandledThroughAt = slot!.Value;
s.FailureCount = 0;
s.RetryAfter = null;
}
});
switch (saved)
{
case CommitResult.Duplicate:
// Dieser Plantermin hat schon ein Blatt (anderer Lauf war schneller): nichts doppelt anlegen.
return new ScheduleRunOutcome(ScheduleRunResult.NotDue, LearningScheduleStatus.Created, "Für diesen Termin gibt es schon ein Blatt.", StatusCodes.Status409Conflict);
case CommitResult.Gone:
return new ScheduleRunOutcome(ScheduleRunResult.NotDue, LearningScheduleStatus.Never, "Den Zeitplan gibt es nicht mehr.", StatusCodes.Status404NotFound);
case CommitResult.Conflict:
return new ScheduleRunOutcome(ScheduleRunResult.Busy, LearningScheduleStatus.Never, "Der Zeitplan wurde gleichzeitig geändert. Bitte erneut versuchen.", StatusCodes.Status409Conflict);
}
await PruneRunsAsync(schedule.Id);
try
{
await notifier.ChangedAsync(set.Id, set.TargetUserId, assign ? LearningChangeKind.Assigned : LearningChangeKind.SetChanged, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogDebug(ex, "Live-Hinweis für Lern-Zeitplan konnte nicht gesendet werden.");
}
await AuditAsync(actor, AuditActions.LearningScheduleRun, schedule.Id,
$"{(auto ? "automatisch" : "manuell")}: {schedule.Subject}, Klasse {schedule.GradeLevel}, Stufe {difficulty}, {set.Items.Count} Aufgaben, {(assign ? "zugewiesen" : "Entwurf")}");
return new ScheduleRunOutcome(ScheduleRunResult.Created, status, message, StatusCodes.Status200OK, set.Id, assign);
}
// ---------- Fehler, Aufschub, Überspringen ----------
private async Task<ScheduleRunOutcome> FinishFailureAsync(
LearningSchedule schedule, LearningScheduleTrigger trigger, DateTimeOffset? slot, DateTimeOffset now, TimeZoneInfo zone, int difficulty, AiException ex, CancellationToken ct)
{
var auto = trigger == LearningScheduleTrigger.Auto;
if (ex.Kind == AiErrorKind.LimitReached)
{
// Aufschub, kein Fehler: das Tageslimit gilt je Familientag, deshalb ab Mitternacht erneut.
const string text = "Das Tageslimit der KI ist erreicht – es wird morgen erneut versucht.";
var retry = LearningScheduleRules.NextLocalMidnight(now, zone);
await CommitAsync(
schedule.Id,
new LearningScheduleRun { SlotUtc = slot, Trigger = trigger, Status = LearningScheduleStatus.Deferred, Message = text, Difficulty = difficulty },
null,
s =>
{
s.LastRunAt = now;
s.LastStatus = LearningScheduleStatus.Deferred;
s.LastMessage = text;
if (auto)
{
s.RetryAfter = retry;
}
});
return new ScheduleRunOutcome(ScheduleRunResult.Deferred, LearningScheduleStatus.Deferred, text, StatusCodes.Status429TooManyRequests);
}
var message = FailureText(ex.Kind);
var failures = schedule.FailureCount + 1;
var giveUp = auto && failures >= LearningScheduleRules.MaxFailuresPerSlot;
var stored = giveUp ? $"{message} Dieser Termin wurde nach {failures} Versuchen aufgegeben." : message;
await CommitAsync(
schedule.Id,
new LearningScheduleRun { SlotUtc = slot, Trigger = trigger, Status = LearningScheduleStatus.Failed, Message = Limit(stored), Difficulty = difficulty },
null,
s =>
{
s.LastRunAt = now;
s.LastStatus = LearningScheduleStatus.Failed;
s.LastMessage = Limit(stored);
if (!auto)
{
return;
}
if (giveUp)
{
s.HandledThroughAt = slot!.Value;
s.FailureCount = 0;
s.RetryAfter = null;
}
else
{
s.FailureCount = failures;
s.RetryAfter = now + LearningScheduleRules.RetryDelay(failures);
}
});
if (giveUp)
{
await AuditAsync(AutomationActor, AuditActions.LearningScheduleRun, schedule.Id, $"Termin aufgegeben nach {failures} Fehlversuchen ({ex.Label})");
}
return new ScheduleRunOutcome(ScheduleRunResult.Failed, LearningScheduleStatus.Failed, message, ex.HttpStatus);
}
private async Task<ScheduleRunOutcome> FinishNonGenerationAsync(
LearningSchedule schedule,
LearningScheduleTrigger trigger,
DateTimeOffset? slot,
DateTimeOffset now,
int difficulty,
LearningScheduleStatus status,
string message,
bool pause,
string actor,
CancellationToken ct)
{
var auto = trigger == LearningScheduleTrigger.Auto;
await CommitAsync(
schedule.Id,
new LearningScheduleRun { SlotUtc = slot, Trigger = trigger, Status = status, Message = Limit(message), Difficulty = difficulty },
null,
s =>
{
s.LastRunAt = now;
s.LastStatus = status;
s.LastMessage = Limit(message);
if (auto)
{
s.HandledThroughAt = slot!.Value; // der Termin gilt als erledigt – kein Dauerfeuer
s.FailureCount = 0;
s.RetryAfter = null;
}
if (pause)
{
s.IsActive = false;
}
});
if (pause)
{
await AuditAsync(actor, AuditActions.LearningSchedulePaused, schedule.Id, "automatisch pausiert");
}
return new ScheduleRunOutcome(ScheduleRunResult.Skipped, status, message, StatusCodes.Status409Conflict);
}
// ---------- Speichern ----------
private enum CommitResult
{
Saved,
Gone,
Duplicate,
Conflict,
}
/// <summary>
/// Schreibt Lauf-Eintrag, optional das Blatt und den neuen Stand des Zeitplans in <b>einem</b> Speichern und gibt die Sperre frei.
/// Der Zeitplan wird frisch geladen, damit gleichzeitige Änderungen der Eltern (Uhrzeit, Pause …) erhalten bleiben.
/// </summary>
private async Task<CommitResult> CommitAsync(Guid id, LearningScheduleRun run, LearningSet? set, Action<LearningSchedule> apply)
{
for (var attempt = 0; attempt < 3; attempt++)
{
db.ChangeTracker.Clear();
var schedule = await db.LearningSchedules.SingleOrDefaultAsync(s => s.Id == id, CancellationToken.None);
if (schedule is null)
{
return CommitResult.Gone; // inzwischen gelöscht: ein erzeugtes Blatt wird nicht mehr gespeichert
}
apply(schedule);
schedule.LockedUntil = null;
run.ScheduleId = id;
db.LearningScheduleRuns.Add(run);
if (set is not null)
{
db.LearningSets.Add(set);
}
try
{
await db.SaveChangesAsync(CancellationToken.None);
return CommitResult.Saved;
}
catch (DbUpdateConcurrencyException)
{
// gleichzeitige Änderung der Eltern: neu laden und noch einmal
}
catch (DbUpdateException ex) when (set is not null)
{
logger.LogInformation(ex, "Lern-Zeitplan {Schedule}: Plantermin schon vergeben.", id);
db.ChangeTracker.Clear();
await db.LearningSchedules.Where(s => s.Id == id).ExecuteUpdateAsync(s => s.SetProperty(x => x.LockedUntil, (DateTimeOffset?)null), CancellationToken.None);
return CommitResult.Duplicate;
}
}
db.ChangeTracker.Clear();
await db.LearningSchedules.Where(s => s.Id == id).ExecuteUpdateAsync(s => s.SetProperty(x => x.LockedUntil, (DateTimeOffset?)null), CancellationToken.None);
return CommitResult.Conflict;
}
/// <summary>Alte Läufe aufräumen (die jüngsten <see cref="LearningScheduleRules.KeepRuns"/> bleiben).</summary>
private async Task PruneRunsAsync(Guid scheduleId)
{
db.ChangeTracker.Clear();
var keep = await db.LearningScheduleRuns.AsNoTracking()
.Where(r => r.ScheduleId == scheduleId)
.OrderByDescending(r => r.CreatedAt)
.Select(r => r.Id)
.Take(LearningScheduleRules.KeepRuns)
.ToListAsync(CancellationToken.None);
await db.LearningScheduleRuns.Where(r => r.ScheduleId == scheduleId && !keep.Contains(r.Id)).ExecuteDeleteAsync(CancellationToken.None);
}
private async Task AuditAsync(string actor, string action, Guid scheduleId, string details)
{
try
{
db.ChangeTracker.Clear();
await audit.WriteAsync(actor, action, "LearningSchedule", scheduleId.ToString(), null, details, CancellationToken.None);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Audit-Eintrag für Lern-Zeitplan konnte nicht geschrieben werden.");
}
}
// ---------- Hilfen ----------
/// <summary>Der Ersteller muss noch aktiv sein und weiterhin <c>LearningManage</c> und <c>AiUse</c> haben.</summary>
private async Task<bool> OwnerMayRunAsync(Guid ownerId, DateTimeOffset now, CancellationToken ct)
{
var owner = await db.Users.AsNoTracking().Where(u => u.Id == ownerId).Select(u => new { u.IsActive, u.ExpiresAt }).SingleOrDefaultAsync(ct);
if (owner is not { IsActive: true } || (owner.ExpiresAt is { } expires && expires <= now))
{
return false;
}
var roles = await db.GetRoleMapAsync(ct);
if (!roles.TryGetValue(ownerId, out var role))
{
return false;
}
var options = (await settingsProvider.GetAsync(ct)).ToPermissionOptions();
return PermissionMatrix.For(Permission.LearningManage, role, options) == AccessLevel.Full
&& PermissionMatrix.For(Permission.AiUse, role, options) == AccessLevel.Full;
}
internal static LearningDifficultyCurve CurveFor(int difficulty) => difficulty switch
{
<= 1 => LearningDifficultyCurve.Easy,
2 => LearningDifficultyCurve.Medium,
_ => LearningDifficultyCurve.Hard,
};
internal static string TitleWithDate(string title, DateTimeOffset now, TimeZoneInfo zone)
{
var date = FamilyTime.LocalDate(now, zone);
var suffix = $" ({date:dd.MM.})";
title = title.Trim();
var room = LearningSet.TitleMaxLength - suffix.Length;
return (title.Length <= room ? title : title[..room].TrimEnd()) + suffix;
}
/// <summary>Feste, bewusst allgemeine Texte (nie Anbieter-Antworten oder Aufgabeninhalte).</summary>
internal static string FailureText(AiErrorKind kind) => kind switch
{
AiErrorKind.NotConfigured => "Es ist noch kein KI-Anbieter eingerichtet.",
AiErrorKind.Misconfigured => "Der KI-Anbieter ist nicht richtig eingerichtet.",
AiErrorKind.Unreachable => "Der KI-Anbieter ist nicht erreichbar.",
AiErrorKind.Timeout => "Der KI-Anbieter hat zu lange gebraucht.",
AiErrorKind.Unauthorized => "Der KI-Anbieter hat den Zugang abgelehnt.",
AiErrorKind.RateLimited or AiErrorKind.Unavailable => "Der KI-Anbieter ist gerade überlastet.",
AiErrorKind.BadResponse => "Die KI hat keine brauchbaren Aufgaben geliefert.",
_ => "Der KI-Aufruf hat nicht geklappt.",
};
private static string Limit(string text) =>
text.Length <= LearningSchedule.MessageMaxLength ? text : text[..(LearningSchedule.MessageMaxLength - 1)] + "…";
}
@@ -0,0 +1,36 @@
namespace FamilyPlanner.Api.Features.Learning;
/// <summary>
/// Hintergrunddienst der Lern-Automatik (Phase 11c): prüft jede Minute (über <see cref="TimeProvider"/>, im Test steuerbar), welche Zeitpläne fällig sind,
/// und lässt <see cref="LearningScheduleRunner"/> je ein Blatt erzeugen. Idempotent und ohne Doppellauf (Sperre und eindeutiger Plantermin im Runner);
/// nach einem Neustart wird sofort geprüft, damit ein verpasster Termin nachgeholt wird (genau einer, kein Rückstau).
/// </summary>
internal sealed class LearningScheduleService(IServiceScopeFactory scopeFactory, TimeProvider timeProvider, ILogger<LearningScheduleService> logger)
: BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(Interval, timeProvider);
do
{
try
{
await SweepAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Die Lern-Automatik konnte nicht ausgeführt werden.");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
/// <summary>Ein Durchlauf; liefert die Zahl erzeugter Blätter.</summary>
internal async Task<int> SweepAsync(CancellationToken ct)
{
await using var scope = scopeFactory.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<LearningScheduleRunner>().SweepAsync(ct);
}
}
+2
View File
@@ -49,7 +49,9 @@ builder.Services.AddScoped<IAiProvider, OpenAiCompatibleProvider>();
builder.Services.AddScoped<ChatNotifier>();
builder.Services.AddScoped<LearningNotifier>();
builder.Services.AddScoped<LearningAttemptService>();
builder.Services.AddScoped<LearningScheduleRunner>();
builder.Services.AddHostedService<LearningExpiryService>();
builder.Services.AddHostedService<LearningScheduleService>();
builder.Services.AddHostedService<LearningPhotoRetentionService>();
builder.Services.AddSingleton<ChatTypingThrottle>();
@@ -41,6 +41,11 @@ public static class AuditActions
public const string AiProviderDeleted = "ai.provider_deleted";
public const string LearningPointsCredited = "learning.points_credited";
public const string LearningPointsReversed = "learning.points_reversed";
public const string LearningScheduleCreated = "learning.schedule_created";
public const string LearningScheduleUpdated = "learning.schedule_updated";
public const string LearningScheduleDeleted = "learning.schedule_deleted";
public const string LearningScheduleRun = "learning.schedule_run";
public const string LearningSchedulePaused = "learning.schedule_paused";
public static string DisplayName(string action) => action switch
{
@@ -54,6 +59,11 @@ public static class AuditActions
AiProviderDeleted => "KI-Anbieter gelöscht",
LearningPointsCredited => "Lernpunkte gutgeschrieben",
LearningPointsReversed => "Lernpunkte zurückgebucht",
LearningScheduleCreated => "Lern-Zeitplan angelegt",
LearningScheduleUpdated => "Lern-Zeitplan geändert",
LearningScheduleDeleted => "Lern-Zeitplan gelöscht",
LearningScheduleRun => "Lern-Zeitplan erzeugt Blatt",
LearningSchedulePaused => "Lern-Zeitplan pausiert",
UserCreated => "Benutzer angelegt",
UserUpdated => "Benutzer geändert",
UserDeleted => "Benutzer gelöscht",
@@ -128,6 +128,9 @@ public sealed class LearningSet : AuditableEntity, ISoftDeletable, IHasRowVersio
public string? AiModel { get; set; }
/// <summary>Phase 11c: der Zeitplan, der dieses Blatt automatisch erzeugt hat (<c>null</c> = manuell/Assistent). Kinder sehen das nie.</summary>
public Guid? ScheduleId { get; set; }
public DateTimeOffset? AssignedAt { get; set; }
/// <summary>Wird bei jeder Änderung an den Aufgaben erhöht und sperrt so gleichzeitige Bearbeitung (zusammen mit <see cref="RowVersion"/>).</summary>
@@ -0,0 +1,153 @@
using FamilyPlanner.Domain.Common;
namespace FamilyPlanner.Domain.Learning;
/// <summary>Wochentage eines Zeitplans als Bitmaske (Montag = Bit 0). „Täglich“ sind alle sieben.</summary>
[Flags]
public enum LearningWeekdays
{
None = 0,
Monday = 1,
Tuesday = 2,
Wednesday = 4,
Thursday = 8,
Friday = 16,
Saturday = 32,
Sunday = 64,
Weekdays = Monday | Tuesday | Wednesday | Thursday | Friday,
All = Weekdays | Saturday | Sunday,
}
/// <summary>Ergebnis des letzten Laufs eines Zeitplans.</summary>
public enum LearningScheduleStatus
{
/// <summary>Noch nie gelaufen.</summary>
Never = 0,
/// <summary>Blatt erzeugt (Entwurf oder zugewiesen, je nach Einstellung).</summary>
Created = 1,
/// <summary>Erzeugt, aber als Entwurf statt direkt zugewiesen (zu wenig brauchbare Aufgaben oder zu viele offene Blätter).</summary>
CreatedAsDraft = 2,
/// <summary>Aufgeschoben: Tageslimit der KI erreicht, es wird später erneut versucht.</summary>
Deferred = 3,
/// <summary>Fehlgeschlagen (KI nicht erreichbar, unbrauchbare Antwort …); wird mit Wartezeit erneut versucht.</summary>
Failed = 4,
/// <summary>Übersprungen (zu viele offene Blätter, Kind oder Ersteller nicht mehr gültig).</summary>
Skipped = 5,
}
public enum LearningScheduleTrigger
{
/// <summary>Hintergrunddienst zur Planzeit.</summary>
Auto = 0,
/// <summary>Eltern drücken „Jetzt ausführen“.</summary>
Manual = 1,
}
/// <summary>
/// Zeitplan für automatisch erzeugte Übungsblätter (Phase 11c, ADR-0021): je Kind und Fach, an bestimmten Wochentagen zu einer Uhrzeit der Familienzeitzone.
/// Nur Eltern (<c>LearningManage</c> + <c>AiUse</c>) legen Zeitpläne an; der Hintergrunddienst nutzt die KI im Namen des Erstellers (<see cref="OwnerUserId"/>,
/// dessen Tageslimit gilt). Enthält keine Aufgabeninhalte.
/// </summary>
public sealed class LearningSchedule : AuditableEntity, ISoftDeletable, IHasRowVersion
{
public const int MinCount = 1;
public const int MaxCount = 30;
public const int MaxRaiseEveryRuns = 52;
public const int MessageMaxLength = 200;
/// <summary>Mehr unerledigte (Entwurf/zugewiesen ohne Versuch) Blätter dieses Zeitplans: kein neues Blatt (schützt Kontingent und Kind).</summary>
public const int MaxOpenSheets = 5;
/// <summary>Das Kind (Rolle Kind; bloße Id ohne Fremdschlüssel wie bei Blättern).</summary>
public Guid TargetUserId { get; set; }
/// <summary>Elternteil, in dessen Namen die KI aufgerufen wird (sein Tageslimit und seine Rechte gelten zur Laufzeit).</summary>
public Guid OwnerUserId { get; set; }
public string Subject { get; set; } = LearningSubjects.Math;
public int GradeLevel { get; set; } = 3;
public int ItemCount { get; set; } = 10;
/// <summary>Schwierigkeit des ersten Blatts (1 = leicht, 3 = schwer).</summary>
public int StartDifficulty { get; set; } = 1;
/// <summary>Nach so vielen erzeugten Blättern steigt die Schwierigkeit um eine Stufe (höchstens 3); 0 = bleibt gleich.</summary>
public int RaiseEveryRuns { get; set; }
/// <summary>Optionaler Wunsch an die KI (wie beim Entwurf, höchstens 300 Zeichen, nur Thema/Stil).</summary>
public string? Extra { get; set; }
public LearningWeekdays Weekdays { get; set; } = LearningWeekdays.All;
/// <summary>Uhrzeit in der Familienzeitzone (Europe/Berlin).</summary>
public TimeOnly TimeOfDay { get; set; } = new(15, 0);
/// <summary><c>true</c>: das Blatt wird direkt zugewiesen. <c>false</c> (Standard): Entwurf, den Eltern erst prüfen.</summary>
public bool AutoAssign { get; set; }
public bool IsActive { get; set; } = true;
/// <summary>Alle Plantermine bis einschließlich dieses Zeitpunkts sind erledigt oder übersprungen (Grundlage der Idempotenz).</summary>
public DateTimeOffset HandledThroughAt { get; set; }
/// <summary>Frühester nächster Versuch nach einem Fehler bzw. erreichtem Tageslimit.</summary>
public DateTimeOffset? RetryAfter { get; set; }
/// <summary>Fehlversuche für den aktuellen Plantermin (0 nach Erfolg oder nach dem Aufgeben des Termins).</summary>
public int FailureCount { get; set; }
/// <summary>Wie viele Blätter dieser Zeitplan bisher erzeugt hat (bestimmt die Schwierigkeit).</summary>
public int RunCount { get; set; }
public DateTimeOffset? LastRunAt { get; set; }
public LearningScheduleStatus LastStatus { get; set; }
/// <summary>Kurze Meldung zum letzten Lauf – nie Aufgaben- oder Antwortinhalte und kein Fehlertext des Anbieters.</summary>
public string? LastMessage { get; set; }
public Guid? LastSetId { get; set; }
/// <summary>Sperre gegen gleichzeitige Läufe (mehrere Instanzen, Doppelklick); läuft von selbst ab.</summary>
public DateTimeOffset? LockedUntil { get; set; }
public List<LearningScheduleRun> Runs { get; set; } = [];
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Guid? DeletedBy { get; set; }
public byte[] RowVersion { get; set; } = [];
}
/// <summary>
/// Ein Lauf eines Zeitplans (Protokoll ohne Inhalt). Je Plantermin gibt es höchstens einen <see cref="LearningScheduleStatus.Created"/>-Lauf
/// (eindeutiger Index) – das ist die Sicherung gegen doppelte Blätter. Alte Läufe werden aufgeräumt.
/// </summary>
public sealed class LearningScheduleRun : AuditableEntity
{
public Guid ScheduleId { get; set; }
/// <summary>Plantermin (UTC); <c>null</c> bei „Jetzt ausführen“.</summary>
public DateTimeOffset? SlotUtc { get; set; }
public LearningScheduleTrigger Trigger { get; set; }
public LearningScheduleStatus Status { get; set; }
public Guid? SetId { get; set; }
public string? Message { get; set; }
public int Difficulty { get; set; }
}
@@ -0,0 +1,154 @@
using FamilyPlanner.Domain.Common;
namespace FamilyPlanner.Domain.Learning;
/// <summary>
/// Reine Regeln der Lern-Automatik (Phase 11c): Plantermine in der Familienzeitzone (Sommerzeit-fest über <see cref="FamilyTime"/>), steigende Schwierigkeit,
/// Wartezeiten nach Fehlern. Keine Datenbank, keine Uhr – alles bekommt die Zeit übergeben und ist deshalb unit-testbar.
/// </summary>
public static class LearningScheduleRules
{
/// <summary>Nach so vielen Fehlversuchen für einen Plantermin wird er aufgegeben (jeder Versuch kostet KI-Kontingent).</summary>
public const int MaxFailuresPerSlot = 5;
/// <summary>Sperrdauer eines laufenden Laufs; länger als der längste KI-Aufruf, kürzer als der nächste Takt der Wiederholungen.</summary>
public static readonly TimeSpan LockDuration = TimeSpan.FromMinutes(10);
/// <summary>Läufe, die älter sind, werden aufgeräumt; mindestens diese Anzahl je Zeitplan bleibt.</summary>
public const int KeepRuns = 20;
private static readonly TimeSpan[] RetryDelays =
[
TimeSpan.FromMinutes(15), TimeSpan.FromMinutes(30), TimeSpan.FromHours(1), TimeSpan.FromHours(2), TimeSpan.FromHours(4),
];
public static bool Includes(LearningWeekdays days, DayOfWeek day) => (days & Flag(day)) != 0;
public static LearningWeekdays Flag(DayOfWeek day) => day switch
{
DayOfWeek.Monday => LearningWeekdays.Monday,
DayOfWeek.Tuesday => LearningWeekdays.Tuesday,
DayOfWeek.Wednesday => LearningWeekdays.Wednesday,
DayOfWeek.Thursday => LearningWeekdays.Thursday,
DayOfWeek.Friday => LearningWeekdays.Friday,
DayOfWeek.Saturday => LearningWeekdays.Saturday,
_ => LearningWeekdays.Sunday,
};
/// <summary>Gültige Maske: mindestens ein Tag und nur bekannte Bits.</summary>
public static bool IsValidWeekdays(LearningWeekdays days) => days != LearningWeekdays.None && (days & ~LearningWeekdays.All) == 0;
/// <summary>Plantermin (UTC) eines Kalendertags: Wanduhrzeit der Zeitzone, Sommerzeitlücke nach vorn verschoben, doppelte Stunde = erstes Auftreten.</summary>
public static DateTimeOffset SlotOn(DateOnly localDate, TimeOnly time, TimeZoneInfo zone) =>
FamilyTime.ToUtc(localDate.ToDateTime(time), zone);
/// <summary>Der jüngste Plantermin, der höchstens <paramref name="now"/> ist; <c>null</c> ohne Wochentage.</summary>
public static DateTimeOffset? LatestSlotAtOrBefore(DateTimeOffset now, LearningWeekdays days, TimeOnly time, TimeZoneInfo zone)
{
if (!IsValidWeekdays(days))
{
return null;
}
var today = FamilyTime.LocalDate(now, zone);
for (var back = 0; back <= 8; back++)
{
var date = today.AddDays(-back);
if (!Includes(days, date.DayOfWeek))
{
continue;
}
var slot = SlotOn(date, time, zone);
if (slot <= now)
{
return slot;
}
}
return null;
}
/// <summary>Der nächste Plantermin nach <paramref name="now"/>; <c>null</c> ohne Wochentage.</summary>
public static DateTimeOffset? NextSlotAfter(DateTimeOffset now, LearningWeekdays days, TimeOnly time, TimeZoneInfo zone)
{
if (!IsValidWeekdays(days))
{
return null;
}
var today = FamilyTime.LocalDate(now, zone);
for (var ahead = 0; ahead <= 8; ahead++)
{
var date = today.AddDays(ahead);
if (!Includes(days, date.DayOfWeek))
{
continue;
}
var slot = SlotOn(date, time, zone);
if (slot > now)
{
return slot;
}
}
return null;
}
/// <summary>
/// Der Plantermin, der jetzt gelaufen werden soll: der jüngste vergangene Termin, der noch nicht erledigt ist (<paramref name="handledThrough"/>) –
/// bei längerem Ausfall genau einer, kein Rückstau. <c>null</c>, wenn nichts fällig ist oder eine Wartezeit läuft.
/// </summary>
public static DateTimeOffset? DueSlot(
DateTimeOffset now, DateTimeOffset handledThrough, DateTimeOffset? retryAfter, LearningWeekdays days, TimeOnly time, TimeZoneInfo zone)
{
if (retryAfter is { } wait && wait > now)
{
return null;
}
return LatestSlotAtOrBefore(now, days, time, zone) is { } slot && slot > handledThrough ? slot : null;
}
/// <summary>Schwierigkeit des nächsten Blatts: Startstufe, alle <paramref name="raiseEveryRuns"/> Blätter eine Stufe mehr, höchstens 3.</summary>
public static int DifficultyFor(int startDifficulty, int raiseEveryRuns, int runCount)
{
var start = Math.Clamp(startDifficulty, 1, 3);
var steps = raiseEveryRuns > 0 ? Math.Max(0, runCount) / raiseEveryRuns : 0;
return Math.Min(3, start + steps);
}
/// <summary>Wartezeit nach dem <paramref name="failureCount"/>. Fehlversuch (1-basiert): 15 min, 30 min, 1 h, 2 h, 4 h.</summary>
public static TimeSpan RetryDelay(int failureCount) => RetryDelays[Math.Clamp(failureCount, 1, RetryDelays.Length) - 1];
/// <summary>Beginn des nächsten Kalendertags (Tageslimit der KI gilt je Familientag): frühester neuer Versuch nach erreichtem Limit.</summary>
public static DateTimeOffset NextLocalMidnight(DateTimeOffset now, TimeZoneInfo zone) =>
FamilyTime.StartOfDayUtc(FamilyTime.LocalDate(now, zone).AddDays(1), zone);
/// <summary>Lesbare Kurzbeschreibung der Wochentage („täglich“, „Mo–Fr“, „Mo, Mi, Sa“).</summary>
public static string DescribeWeekdays(LearningWeekdays days)
{
if (days == LearningWeekdays.All)
{
return "täglich";
}
if (days == LearningWeekdays.Weekdays)
{
return "Mo–Fr";
}
string[] names = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
var picked = new List<string>();
for (var i = 0; i < 7; i++)
{
if (((int)days & (1 << i)) != 0)
{
picked.Add(names[i]);
}
}
return string.Join(", ", picked);
}
}
@@ -81,6 +81,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<LearningPhoto> LearningPhotos => Set<LearningPhoto>();
public DbSet<LearningSchedule> LearningSchedules => Set<LearningSchedule>();
public DbSet<LearningScheduleRun> LearningScheduleRuns => Set<LearningScheduleRun>();
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -26,6 +26,7 @@ internal sealed class LearningSetConfiguration : IEntityTypeConfiguration<Learni
// Kind-Ansicht „meine Blätter“ und Eltern-Übersicht.
builder.HasIndex(s => new { s.TargetUserId, s.Status });
builder.HasIndex(s => s.Status);
builder.HasIndex(s => s.ScheduleId);
}
}
@@ -95,3 +96,40 @@ internal sealed class LearningPhotoConfiguration : IEntityTypeConfiguration<Lear
builder.HasIndex(p => new { p.AttemptId, p.ItemId }).IsUnique();
}
}
internal sealed class LearningScheduleConfiguration : IEntityTypeConfiguration<LearningSchedule>
{
public void Configure(EntityTypeBuilder<LearningSchedule> builder)
{
builder.ToTable("LearningSchedules");
builder.Property(s => s.Subject).HasMaxLength(LearningSet.SubjectMaxLength).IsRequired();
builder.Property(s => s.Extra).HasMaxLength(300);
builder.Property(s => s.Weekdays).HasConversion<int>();
builder.Property(s => s.LastStatus).HasConversion<string>().HasMaxLength(20);
builder.Property(s => s.LastMessage).HasMaxLength(LearningSchedule.MessageMaxLength);
builder.Property(s => s.RowVersion).IsRowVersion();
// Kind und Ersteller sind bloße Ids (kein Fremdschlüssel, wie bei Blättern und Aufgaben).
builder.HasMany(s => s.Runs).WithOne().HasForeignKey(r => r.ScheduleId).OnDelete(DeleteBehavior.Cascade);
// Der Hintergrunddienst sucht aktive Zeitpläne.
builder.HasIndex(s => new { s.IsActive, s.IsDeleted });
builder.HasIndex(s => s.TargetUserId);
}
}
internal sealed class LearningScheduleRunConfiguration : IEntityTypeConfiguration<LearningScheduleRun>
{
public void Configure(EntityTypeBuilder<LearningScheduleRun> builder)
{
builder.ToTable("LearningScheduleRuns");
builder.Property(r => r.Trigger).HasConversion<string>().HasMaxLength(20);
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20);
builder.Property(r => r.Message).HasMaxLength(LearningSchedule.MessageMaxLength);
// Je Plantermin höchstens ein erzeugtes Blatt – die letzte Sicherung gegen Doppelläufe (mehrere Instanzen, Wiederholung nach Absturz).
builder.HasIndex(r => new { r.ScheduleId, r.SlotUtc }).IsUnique()
.HasFilter("[SlotUtc] IS NOT NULL AND [Status] IN ('Created', 'CreatedAsDraft')");
builder.HasIndex(r => new { r.ScheduleId, r.CreatedAt });
}
}
@@ -0,0 +1,139 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase11cLearningAuto : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ScheduleId",
table: "LearningSets",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateTable(
name: "LearningSchedules",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
TargetUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
OwnerUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Subject = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
GradeLevel = table.Column<int>(type: "int", nullable: false),
ItemCount = table.Column<int>(type: "int", nullable: false),
StartDifficulty = table.Column<int>(type: "int", nullable: false),
RaiseEveryRuns = table.Column<int>(type: "int", nullable: false),
Extra = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: true),
Weekdays = table.Column<int>(type: "int", nullable: false),
TimeOfDay = table.Column<TimeOnly>(type: "time", nullable: false),
AutoAssign = table.Column<bool>(type: "bit", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
HandledThroughAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
RetryAfter = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
FailureCount = table.Column<int>(type: "int", nullable: false),
RunCount = table.Column<int>(type: "int", nullable: false),
LastRunAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
LastStatus = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
LastMessage = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
LastSetId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
LockedUntil = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LearningSchedules", x => x.Id);
});
migrationBuilder.CreateTable(
name: "LearningScheduleRuns",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ScheduleId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SlotUtc = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
Trigger = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
SetId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Message = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
Difficulty = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LearningScheduleRuns", x => x.Id);
table.ForeignKey(
name: "FK_LearningScheduleRuns_LearningSchedules_ScheduleId",
column: x => x.ScheduleId,
principalTable: "LearningSchedules",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_LearningSets_ScheduleId",
table: "LearningSets",
column: "ScheduleId");
migrationBuilder.CreateIndex(
name: "IX_LearningScheduleRuns_ScheduleId_CreatedAt",
table: "LearningScheduleRuns",
columns: new[] { "ScheduleId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_LearningScheduleRuns_ScheduleId_SlotUtc",
table: "LearningScheduleRuns",
columns: new[] { "ScheduleId", "SlotUtc" },
unique: true,
filter: "[SlotUtc] IS NOT NULL AND [Status] IN ('Created', 'CreatedAsDraft')");
migrationBuilder.CreateIndex(
name: "IX_LearningSchedules_IsActive_IsDeleted",
table: "LearningSchedules",
columns: new[] { "IsActive", "IsDeleted" });
migrationBuilder.CreateIndex(
name: "IX_LearningSchedules_IsDeleted",
table: "LearningSchedules",
column: "IsDeleted");
migrationBuilder.CreateIndex(
name: "IX_LearningSchedules_TargetUserId",
table: "LearningSchedules",
column: "TargetUserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LearningScheduleRuns");
migrationBuilder.DropTable(
name: "LearningSchedules");
migrationBuilder.DropIndex(
name: "IX_LearningSets_ScheduleId",
table: "LearningSets");
migrationBuilder.DropColumn(
name: "ScheduleId",
table: "LearningSets");
}
}
}
@@ -963,6 +963,174 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.ToTable("LearningPhotos", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningSchedule", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<bool>("AutoAssign")
.HasColumnType("bit");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Extra")
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<int>("FailureCount")
.HasColumnType("int");
b.Property<int>("GradeLevel")
.HasColumnType("int");
b.Property<DateTimeOffset>("HandledThroughAt")
.HasColumnType("datetimeoffset");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("ItemCount")
.HasColumnType("int");
b.Property<string>("LastMessage")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("LastSetId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LastStatus")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTimeOffset?>("LockedUntil")
.HasColumnType("datetimeoffset");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uniqueidentifier");
b.Property<int>("RaiseEveryRuns")
.HasColumnType("int");
b.Property<DateTimeOffset?>("RetryAfter")
.HasColumnType("datetimeoffset");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("RunCount")
.HasColumnType("int");
b.Property<int>("StartDifficulty")
.HasColumnType("int");
b.Property<string>("Subject")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<Guid>("TargetUserId")
.HasColumnType("uniqueidentifier");
b.Property<TimeOnly>("TimeOfDay")
.HasColumnType("time");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("Weekdays")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("IsDeleted");
b.HasIndex("TargetUserId");
b.HasIndex("IsActive", "IsDeleted");
b.ToTable("LearningSchedules", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningScheduleRun", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("Difficulty")
.HasColumnType("int");
b.Property<string>("Message")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<Guid>("ScheduleId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("SetId")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("SlotUtc")
.HasColumnType("datetimeoffset");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Trigger")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("ScheduleId", "CreatedAt");
b.HasIndex("ScheduleId", "SlotUtc")
.IsUnique()
.HasFilter("[SlotUtc] IS NOT NULL AND [Status] IN ('Created', 'CreatedAsDraft')");
b.ToTable("LearningScheduleRuns", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningSet", b =>
{
b.Property<Guid>("Id")
@@ -1026,6 +1194,9 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<Guid?>("ScheduleId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("ShowSolutionsAfterSubmit")
.HasColumnType("bit");
@@ -1071,6 +1242,8 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.HasIndex("IsDeleted");
b.HasIndex("ScheduleId");
b.HasIndex("Status");
b.HasIndex("TargetUserId", "Status");
@@ -2231,6 +2404,15 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningScheduleRun", b =>
{
b.HasOne("FamilyPlanner.Domain.Learning.LearningSchedule", null)
.WithMany("Runs")
.HasForeignKey("ScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Shopping.ShoppingItem", b =>
{
b.HasOne("FamilyPlanner.Domain.Shopping.ShoppingList", null)
@@ -2328,6 +2510,11 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.Navigation("Answers");
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningSchedule", b =>
{
b.Navigation("Runs");
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningSet", b =>
{
b.Navigation("Attempts");
@@ -0,0 +1,77 @@
using FamilyPlanner.Domain.Learning;
using FluentValidation;
namespace FamilyPlanner.Shared.Learning;
// =====================================================================================================================
// Automatische Übungsblätter (Phase 11c, ADR-0021): Zeitplan je Kind und Fach. Nur Eltern (LearningManage + AiUse);
// Kinder sehen weder Zeitpläne noch die Herkunft eines Blatts.
// =====================================================================================================================
public static class LearningScheduleLimits
{
public const int ExtraMaxLength = LearningAiDraft.ExtraMaxLength;
}
/// <summary>Ein Zeitplan samt Stand. <see cref="NextRunAt"/> ist der nächste Plantermin (UTC; Anzeige in Europe/Berlin) oder <c>null</c>, wenn pausiert.</summary>
public sealed record LearningScheduleDto(
Guid Id,
Guid TargetUserId,
string ChildName,
string Subject,
int GradeLevel,
int ItemCount,
int StartDifficulty,
int RaiseEveryRuns,
int CurrentDifficulty,
string? Extra,
LearningWeekdays Weekdays,
TimeOnly TimeOfDay,
bool AutoAssign,
bool IsActive,
DateTimeOffset? NextRunAt,
DateTimeOffset? RetryAfter,
DateTimeOffset? LastRunAt,
LearningScheduleStatus LastStatus,
string? LastMessage,
Guid? LastSetId,
int RunCount,
byte[] Version);
/// <summary>Anlegen und Ändern eines Zeitplans. <see cref="Version"/> wird nur beim Ändern geprüft (409 bei veraltetem Stand).</summary>
public sealed record SaveLearningScheduleRequest(
Guid TargetUserId,
string Subject,
int GradeLevel,
int ItemCount = 10,
int StartDifficulty = 1,
int RaiseEveryRuns = 0,
string? Extra = null,
LearningWeekdays Weekdays = LearningWeekdays.All,
TimeOnly TimeOfDay = default,
bool AutoAssign = false,
bool IsActive = true,
byte[]? Version = null);
/// <summary>Ergebnis von „Jetzt ausführen“: das erzeugte Blatt (Entwurf oder zugewiesen) und der aktualisierte Zeitplan.</summary>
public sealed record LearningScheduleRunResultDto(LearningScheduleDto Schedule, Guid SetId, bool Assigned, string Message);
public sealed class SaveLearningScheduleRequestValidator : AbstractValidator<SaveLearningScheduleRequest>
{
public SaveLearningScheduleRequestValidator()
{
RuleFor(r => r.TargetUserId).NotEmpty().WithMessage("Bitte ein Kind auswählen.");
RuleFor(r => r.Subject).Must(LearningSubjects.IsValid).WithMessage("Bitte ein Fach wählen (Mathe oder Deutsch).");
RuleFor(r => r.GradeLevel).InclusiveBetween(LearningSet.MinGradeLevel, LearningSet.MaxGradeLevel)
.WithMessage("Die Klassenstufe muss zwischen 1 und 6 liegen.");
RuleFor(r => r.ItemCount).InclusiveBetween(LearningSchedule.MinCount, LearningSchedule.MaxCount)
.WithMessage($"Die Anzahl muss zwischen {LearningSchedule.MinCount} und {LearningSchedule.MaxCount} liegen.");
RuleFor(r => r.StartDifficulty).InclusiveBetween(1, 3).WithMessage("Die Startschwierigkeit muss 1 (leicht) bis 3 (schwer) sein.");
RuleFor(r => r.RaiseEveryRuns).InclusiveBetween(0, LearningSchedule.MaxRaiseEveryRuns)
.WithMessage($"Die Steigerung muss zwischen 0 (nie) und {LearningSchedule.MaxRaiseEveryRuns} Blättern liegen.");
RuleFor(r => r.Weekdays).Must(LearningScheduleRules.IsValidWeekdays).WithMessage("Bitte mindestens einen Wochentag wählen.");
RuleFor(r => r.TimeOfDay).Must(t => t.Second == 0 && t.Millisecond == 0).WithMessage("Die Uhrzeit darf keine Sekunden haben.");
RuleFor(r => r.Extra).Must(e => e is null || e.Trim().Length <= LearningScheduleLimits.ExtraMaxLength)
.WithMessage($"Der Zusatz darf höchstens {LearningScheduleLimits.ExtraMaxLength} Zeichen haben.");
}
}
@@ -0,0 +1,196 @@
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Domain.Learning;
namespace FamilyPlanner.UnitTests.Learning;
/// <summary>Plantermine, Schwierigkeit und Wartezeiten der Lern-Automatik (Phase 11c), alles mit übergebener Uhr.</summary>
public sealed class LearningScheduleRulesTests
{
private static readonly TimeZoneInfo Zone = FamilyTime.Berlin;
private static readonly TimeOnly Seven = new(7, 0);
private static DateTimeOffset Utc(int month, int day, int hour, int minute = 0) => new(2026, month, day, hour, minute, 0, TimeSpan.Zero);
// 2026-10-12 ist ein Montag (Sommerzeit, UTC+2); 2026-11-02 ein Montag (Winterzeit, UTC+1).
[Fact]
public void A_daily_slot_uses_the_wall_clock_of_the_family_zone()
{
var summer = LearningScheduleRules.SlotOn(new DateOnly(2026, 10, 12), Seven, Zone);
var winter = LearningScheduleRules.SlotOn(new DateOnly(2026, 11, 2), Seven, Zone);
Assert.Equal(Utc(10, 12, 5), summer);
Assert.Equal(Utc(11, 2, 6), winter);
}
[Fact]
public void The_slot_in_the_spring_gap_moves_forward_and_in_the_autumn_overlap_takes_the_first_occurrence()
{
var gap = LearningScheduleRules.SlotOn(new DateOnly(2026, 3, 29), new TimeOnly(2, 30), Zone); // 02:30 gibt es nicht
var overlap = LearningScheduleRules.SlotOn(new DateOnly(2026, 10, 25), new TimeOnly(2, 30), Zone); // 02:30 gibt es zweimal
Assert.Equal(Utc(3, 29, 1, 30), gap); // = 03:30 Sommerzeit
Assert.Equal(Utc(10, 25, 0, 30), overlap); // erstes Auftreten (Sommerzeit)
}
[Fact]
public void The_latest_slot_is_today_after_the_time_and_yesterday_before_it()
{
// 07:00 Berlin = 05:00 UTC am 12.10.
Assert.Equal(Utc(10, 12, 5), LearningScheduleRules.LatestSlotAtOrBefore(Utc(10, 12, 5), LearningWeekdays.All, Seven, Zone)); // genau zur Planzeit
Assert.Equal(Utc(10, 12, 5), LearningScheduleRules.LatestSlotAtOrBefore(Utc(10, 12, 15), LearningWeekdays.All, Seven, Zone));
Assert.Equal(Utc(10, 11, 5), LearningScheduleRules.LatestSlotAtOrBefore(Utc(10, 12, 4, 59), LearningWeekdays.All, Seven, Zone));
}
[Fact]
public void Weekdays_limit_the_slots_and_skip_the_weekend()
{
// Samstag, 17.10.2026 mittags: der jüngste Mo–Fr-Termin war Freitag 16.10.
var latest = LearningScheduleRules.LatestSlotAtOrBefore(Utc(10, 17, 10), LearningWeekdays.Weekdays, Seven, Zone);
var next = LearningScheduleRules.NextSlotAfter(Utc(10, 17, 10), LearningWeekdays.Weekdays, Seven, Zone);
Assert.Equal(Utc(10, 16, 5), latest);
Assert.Equal(Utc(10, 19, 5), next); // Montag
}
[Fact]
public void A_weekly_schedule_picks_one_day()
{
var wednesday = LearningWeekdays.Wednesday;
Assert.Equal(Utc(10, 14, 5), LearningScheduleRules.NextSlotAfter(Utc(10, 12, 12), wednesday, Seven, Zone));
Assert.Equal(Utc(10, 21, 5), LearningScheduleRules.NextSlotAfter(Utc(10, 14, 5), wednesday, Seven, Zone)); // der aktuelle Termin zählt nicht
Assert.Equal(Utc(10, 7, 5), LearningScheduleRules.LatestSlotAtOrBefore(Utc(10, 12, 12), wednesday, Seven, Zone));
}
[Fact]
public void Without_weekdays_or_with_unknown_bits_there_are_no_slots()
{
Assert.Null(LearningScheduleRules.LatestSlotAtOrBefore(Utc(10, 12, 12), LearningWeekdays.None, Seven, Zone));
Assert.Null(LearningScheduleRules.NextSlotAfter(Utc(10, 12, 12), LearningWeekdays.None, Seven, Zone));
Assert.Null(LearningScheduleRules.NextSlotAfter(Utc(10, 12, 12), (LearningWeekdays)128, Seven, Zone));
Assert.False(LearningScheduleRules.IsValidWeekdays(LearningWeekdays.None));
Assert.False(LearningScheduleRules.IsValidWeekdays((LearningWeekdays)255));
Assert.True(LearningScheduleRules.IsValidWeekdays(LearningWeekdays.Sunday));
}
[Fact]
public void Every_weekday_flag_matches_exactly_its_day()
{
foreach (var day in Enum.GetValues<DayOfWeek>())
{
var flag = LearningScheduleRules.Flag(day);
Assert.True(LearningScheduleRules.Includes(flag, day));
Assert.Equal(1, Enum.GetValues<DayOfWeek>().Count(other => LearningScheduleRules.Includes(flag, other)));
Assert.Equal(7, Enum.GetValues<DayOfWeek>().Count(other => LearningScheduleRules.Includes(LearningWeekdays.All, other)));
}
}
// ---------- Fälligkeit und Idempotenz ----------
[Fact]
public void A_slot_is_due_only_once_after_it_was_handled()
{
var now = Utc(10, 12, 5, 1);
var handled = Utc(10, 11, 5);
var due = LearningScheduleRules.DueSlot(now, handled, null, LearningWeekdays.All, Seven, Zone);
Assert.Equal(Utc(10, 12, 5), due);
// erledigt → nicht noch einmal (auch nicht eine Minute später)
Assert.Null(LearningScheduleRules.DueSlot(now, Utc(10, 12, 5), null, LearningWeekdays.All, Seven, Zone));
Assert.Null(LearningScheduleRules.DueSlot(now.AddHours(3), Utc(10, 12, 5), null, LearningWeekdays.All, Seven, Zone));
}
[Fact]
public void A_schedule_created_after_todays_time_does_not_fire_immediately()
{
// Angelegt um 15:00 Berlin (13:00 UTC); HandledThroughAt = jetzt. Der Termin 07:00 ist schon vorbei.
var created = Utc(10, 12, 13);
Assert.Null(LearningScheduleRules.DueSlot(created, created, null, LearningWeekdays.All, Seven, Zone));
Assert.Equal(Utc(10, 13, 5), LearningScheduleRules.DueSlot(Utc(10, 13, 5), created, null, LearningWeekdays.All, Seven, Zone));
}
[Fact]
public void After_a_long_downtime_exactly_one_slot_is_due_no_backlog()
{
var handled = Utc(10, 1, 5);
var now = Utc(10, 12, 9);
var due = LearningScheduleRules.DueSlot(now, handled, null, LearningWeekdays.All, Seven, Zone);
Assert.Equal(Utc(10, 12, 5), due); // nur der jüngste
Assert.Null(LearningScheduleRules.DueSlot(now, due!.Value, null, LearningWeekdays.All, Seven, Zone));
}
[Fact]
public void A_retry_wait_defers_the_slot_until_it_has_passed()
{
var now = Utc(10, 12, 6);
var handled = Utc(10, 11, 5);
var retryAt = now.AddMinutes(15);
Assert.Null(LearningScheduleRules.DueSlot(now, handled, retryAt, LearningWeekdays.All, Seven, Zone));
Assert.Null(LearningScheduleRules.DueSlot(retryAt.AddTicks(-1), handled, retryAt, LearningWeekdays.All, Seven, Zone));
Assert.Equal(Utc(10, 12, 5), LearningScheduleRules.DueSlot(retryAt, handled, retryAt, LearningWeekdays.All, Seven, Zone));
}
[Fact]
public void A_newer_slot_replaces_an_older_one_that_was_still_retrying()
{
var handled = Utc(10, 11, 5);
var retryAt = Utc(10, 12, 20); // Wartezeit über den nächsten Termin hinaus
// nach Ablauf der Wartezeit ist der jüngste Termin (nicht der alte) fällig
Assert.Equal(Utc(10, 12, 5), LearningScheduleRules.DueSlot(Utc(10, 12, 20), handled, retryAt, LearningWeekdays.All, Seven, Zone));
}
// ---------- Schwierigkeit ----------
[Theory]
[InlineData(1, 0, 0, 1)] // steigt nie
[InlineData(1, 0, 100, 1)]
[InlineData(1, 4, 0, 1)]
[InlineData(1, 4, 3, 1)]
[InlineData(1, 4, 4, 2)] // nach vier Blättern eine Stufe mehr
[InlineData(1, 4, 8, 3)]
[InlineData(1, 4, 400, 3)] // höchstens 3
[InlineData(2, 2, 2, 3)]
[InlineData(3, 1, 5, 3)]
[InlineData(0, 1, 0, 1)] // ungültige Startstufen werden begrenzt
[InlineData(9, 1, 0, 3)]
[InlineData(1, 1, -5, 1)]
public void Difficulty_rises_stepwise_and_is_capped(int start, int raiseEvery, int runs, int expected) =>
Assert.Equal(expected, LearningScheduleRules.DifficultyFor(start, raiseEvery, runs));
// ---------- Wartezeiten ----------
[Theory]
[InlineData(0, 15)]
[InlineData(1, 15)]
[InlineData(2, 30)]
[InlineData(3, 60)]
[InlineData(4, 120)]
[InlineData(5, 240)]
[InlineData(50, 240)]
public void Retry_delays_grow_and_are_capped(int failures, int minutes) =>
Assert.Equal(TimeSpan.FromMinutes(minutes), LearningScheduleRules.RetryDelay(failures));
[Fact]
public void After_the_daily_limit_the_next_attempt_is_local_midnight()
{
// 12.10. 20:00 UTC = 22:00 Berlin → nächste Mitternacht = 13.10. 00:00 Berlin = 12.10. 22:00 UTC
Assert.Equal(Utc(10, 12, 22), LearningScheduleRules.NextLocalMidnight(Utc(10, 12, 20), Zone));
// 12.10. 22:30 UTC = 13.10. 00:30 Berlin → Mitternacht des 14.10. = 13.10. 22:00 UTC
Assert.Equal(Utc(10, 13, 22), LearningScheduleRules.NextLocalMidnight(Utc(10, 12, 22, 30), Zone));
}
[Theory]
[InlineData(LearningWeekdays.All, "täglich")]
[InlineData(LearningWeekdays.Weekdays, "Mo–Fr")]
[InlineData(LearningWeekdays.Monday | LearningWeekdays.Wednesday | LearningWeekdays.Saturday, "Mo, Mi, Sa")]
[InlineData(LearningWeekdays.Sunday, "So")]
public void Weekdays_are_described_for_the_list(LearningWeekdays days, string expected) =>
Assert.Equal(expected, LearningScheduleRules.DescribeWeekdays(days));
}