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:
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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
+2537
File diff suppressed because it is too large.
Load diff
+139
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user