fix: save AI photo suggestions one by one, allow long AI calls, plain AI reasons; docs: phase 10c in ADR-0021, roadmap, architecture
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b3d0546d6b
commit
a6bc071efc
12 files changed
+322
-79
No files matched your search
@@ -87,7 +87,7 @@ Alle Zeitpunkte werden als UTC (`DateTimeOffset`/`DateTime` UTC) gespeichert und
|
||||
| Kalender ✅ | `FamilyCalendar` (Name, Color, IsDefault, Soft Delete), `CalendarPermission` (Rolle **oder** Benutzer → None/Read/WriteOwn/Write), `CalendarEvent` (Title, Location, Notes, IsAllDay, StartUtc, EndUtc, TimeZoneId, RecurrenceRule, ExceptionDates, Uid, PersonIds, ForWholeFamily, Sequence, RowVersion, Soft Delete; Overrides als eigene Zeile mit `SeriesMasterId` + `RecurrenceId`), `IcsFeed` (Owner, Scope, CalendarId, TaskMode, TokenHash, TokenPrefix, LastAccessAt, RevokedAt) – ADR-0015/0016 |
|
||||
| KI ✅ | `AiProviderConfig` (Name, Type Ollama/Claude/OpenAiCompatible, BaseUrl, Model, ApiKeyProtected, TimeoutSeconds, IsActive, AllowImages, Purposes, DailyLimitPerUser, RowVersion, Soft Delete), `AiUsage` (Person, Anbieter, Zweck, Dauer, Tokens, Erfolg/Fehlerart – ohne Inhalt) – ADR-0020 |
|
||||
| Chat ✅ | `ChatChannel` (Family / Direct), `ChatMember` (LastReadAt), `ChatMessage` (Text, HasImage), `ChatAttachment`, `ChatReaction` |
|
||||
| Lernen ✅ (10a) | `LearningSet` (Titel, Fach Mathe/Deutsch, Klassenstufe 1–6, Zielkind, Fälligkeit, Status, Modus Übung/Prüfung, MaxAttempts, TimeLimitMinutes, ShowSolutionsAfterSubmit, ShuffleItems, TotalPoints, GradeThresholds, Source Manual/Ai + AiPrompt/AiProvider/AiModel für 10b, RowVersion, Soft Delete), `LearningItem` (Frage, Antworttyp Number/Text/SingleChoice, Paper vorgesehen, CorrectAnswer, NumberTolerance, Options, Hint, Explanation, Difficulty, Points, RowVersion, Soft Delete), `LearningAttempt` (Nummer, Status, StartedAt, DeadlineAt, SubmittedAt, AutoSubmitted, ItemOrder, Ergebnis/Note, RowVersion), `LearningAnswer` (Antwort, Verdict Open/Correct/Partial/Wrong, Grader Auto/Ai/Parent, AwardedPoints, Reason); `PointsLedgerEntry.LearningSetId` – ADR-0021 |
|
||||
| Lernen ✅ (10a) | `LearningSet` (Titel, Fach Mathe/Deutsch, Klassenstufe 1–6, Zielkind, Fälligkeit, Status, Modus Übung/Prüfung, MaxAttempts, TimeLimitMinutes, ShowSolutionsAfterSubmit, ShuffleItems, TotalPoints, GradeThresholds, Source Manual/Ai + AiPrompt/AiProvider/AiModel für 10b, RowVersion, Soft Delete), `LearningItem` (Frage, Antworttyp Number/Text/SingleChoice, Paper vorgesehen, CorrectAnswer, NumberTolerance, Options, Hint, Explanation, Difficulty, Points, RowVersion, Soft Delete), `LearningAttempt` (Nummer, Status, StartedAt, DeadlineAt, SubmittedAt, AutoSubmitted, ItemOrder, Ergebnis/Note, RowVersion), `LearningAnswer` (Antwort, Verdict Open/Correct/Partial/Wrong, Grader Auto/Ai/Parent, AwardedPoints, Reason, AiConfidence, PhotoPurgedAt), `LearningPhoto` ✅ (10c: Foto der Papierlösung je Versuch und Aufgabe, Bytes in der Datenbank); `FamilySettings.LearningPhotoRetentionDays`; `PointsLedgerEntry.LearningSetId` – ADR-0021 |
|
||||
| Betrieb | `AuditLogEntry` ✅ (ActorName, Action, TargetType/Id/Name, Details, CreatedAt), `PushSubscription` ✅ (UserId, Endpoint + Hash, P256dh, Auth, LastSuccessAt, FailureCount) |
|
||||
|
||||
## 4. Rollen- und Berechtigungsmodell
|
||||
@@ -156,7 +156,7 @@ Das UI bekommt die wirksamen Berechtigungen über `/api/auth/me` und blendet anh
|
||||
| ICS-Feed | `GET /ics/{token}.ics` (öffentlich, Token-gesichert, ETag/Last-Modified) |
|
||||
| Chat ✅ | `GET /api/chat/channels`, `POST /api/chat/direct`, `GET /api/chat/channels/{id}/messages?before=&take=`, `POST …/messages`, `POST …/images` (Multipart), `POST …/read`, `DELETE /api/chat/messages/{id}`, `PUT /api/chat/messages/{id}/reactions`, `GET /api/chat/attachments/{id}` (ADR-0019) |
|
||||
| KI ✅ | Admin: `GET/POST /api/admin/ai/providers`, `PUT/DELETE …/{id}`, `POST …/{id}/test`, `GET /api/admin/ai/usage`; Eltern: `GET /api/ai/status` (ADR-0020) |
|
||||
| Lernen ✅ (10a) | Eltern (`LearningManage`): `GET/POST /api/learning/sets`, `GET/PUT/DELETE …/sets/{id}`, `POST …/sets/{id}/assign`, `POST …/unassign`, `POST …/sets/{id}/items`, `PUT/DELETE …/items/{itemId}`, `POST …/items/order`, `GET /api/learning/review`, `GET /api/learning/attempts/{id}`, `PUT …/attempts/{id}/answers/{itemId}/review`, `POST …/attempts/{id}/confirm`, `POST …/attempts/{id}/reopen`; Kinder (`LearningSolve`): `GET /api/learning/my`, `POST …/my/{setId}/attempts`, `GET …/my/attempts/{id}`, `PUT …/my/attempts/{id}/answers/{itemId}`, `POST …/my/attempts/{id}/submit` (ADR-0021) |
|
||||
| Lernen ✅ (10a) | Eltern (`LearningManage`): `GET/POST /api/learning/sets`, `GET/PUT/DELETE …/sets/{id}`, `POST …/sets/{id}/assign`, `POST …/unassign`, `POST …/sets/{id}/items`, `PUT/DELETE …/items/{itemId}`, `POST …/items/order`, `GET /api/learning/review`, `GET /api/learning/attempts/{id}`, `PUT …/attempts/{id}/answers/{itemId}/review`, `POST …/attempts/{id}/confirm`, `POST …/attempts/{id}/reopen`; Kinder (`LearningSolve`): `GET /api/learning/my`, `POST …/my/{setId}/attempts`, `GET …/my/attempts/{id}`, `PUT …/my/attempts/{id}/answers/{itemId}`, `POST …/my/attempts/{id}/submit`; Fotos (10c): `POST/DELETE /api/learning/my/attempts/{id}/items/{itemId}/photo` (Kind), `GET /api/learning/attempts/{id}/items/{itemId}/photo` (Eltern nach Abgabe, Kind; sonst 404), `POST /api/learning/attempts/{id}/ai-grade` (`LearningManage` + `AiUse`, Foto-KI-Prüfung) (ADR-0021) |
|
||||
| Dashboard ✅ | `GET /api/dashboard` (alle Kacheln der Rolle in einem Request, ADR-0018) |
|
||||
| Hubs | `/hubs/live` (Live-Updates: Einkauf ✅, Essenplan ✅, Aufgaben ✅, Termine ✅, Chat ✅ inkl. Hub-Methode `Typing`, Lernen ✅ (`LearningChanged` ohne Inhalte); das Dashboard lädt bei diesen Ereignissen neu) |
|
||||
| Betrieb | `/health`, `/alive` |
|
||||
|
||||
+2
-2
@@ -126,8 +126,8 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
|
||||
- [x] 10a: Tests: Unit (Zahlenformate, Auto-Bewertung, Zeitlimit, Wiederholen, Punkte-Differenz, Noten, Prüfungsregeln, Kinder-DTO-Sichtbarkeit, Matrix, Validatoren), bUnit (Kinderansicht, Prüfungs-Runner mit steuerbarer Uhr, Elternansicht, Editor, Prüfung, Rechte, Dashboard-Kachel), Integration (Rechte je Rolle inkl. 404, Lösungen-Leck, Auto-Bewertung, Zeitlimit-409 mit echtem Warten, Wiederholungen, Punkte-Workflow inkl. Rückbuchung, Live nur an Berechtigte)
|
||||
- [ ] 10a offen: „Aufgaben aus früheren Blättern wiederverwenden“, Verknüpfung eines Blatts mit einer Familienaufgabe/einem Ämtchen, Standard-Wiederholungen als Familieneinstellung (aktuell Voreinstellung im Editor)
|
||||
- [ ] 10b: KI-Erzeugung per Prompt (z. B. „20 Aufgaben Mathe, 3. Klasse, leicht bis schwer“) als Entwurf, Eltern prüfen/ändern vor dem Zuweisen, Mathe-Lösungen serverseitig nachrechnen (Spalten für Herkunft/Prompt/Anbieter/Modell am Blatt sind vorhanden)
|
||||
- [ ] 10c: Papierlösung per Foto (Upload je Aufgabe/Blatt), Prüfung manuell durch Eltern, danach KI-Prüfung mit Sicherheitsangabe und Eltern-Bestätigung (`LearningAnswerType.Paper`, `LearningGrader.Ai` sind als Werte vorgesehen)
|
||||
- [ ] Foto-Aufbewahrungsfrist (Standard 30 Tage nach Prüfung, einstellbar) – mit 10c
|
||||
- [x] 10c: Papierlösung per Foto (Upload je Aufgabe/Blatt), Prüfung manuell durch Eltern, danach KI-Prüfung mit Sicherheitsangabe und Eltern-Bestätigung (`LearningAnswerType.Paper`, `LearningGrader.Ai` sind als Werte vorgesehen)
|
||||
- [x] Foto-Aufbewahrungsfrist (Standard 30 Tage nach Prüfung, einstellbar, 0 = sofort) – mit 10c
|
||||
|
||||
## Phase 11 – Härtung
|
||||
- [ ] Content-Security-Policy (Basis-Header X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, HSTS sind seit Phase 1 aktiv)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR-0021: Lernaufgaben – manuell oder per KI, digital oder auf Papier
|
||||
|
||||
* Status: akzeptiert (Phase 10; Entscheidungen der Rückfragen am 2026-10-10 eingearbeitet) – **Phase 10a umgesetzt** (Abschnitt „Umsetzung Phase 10a“), 10b und 10c offen
|
||||
* Status: akzeptiert (Phase 10; Entscheidungen der Rückfragen am 2026-10-10 eingearbeitet) – **Phase 10a und 10c umgesetzt** (Abschnitte „Umsetzung Phase 10a/10c“), 10b offen
|
||||
* Datum: 2026-10-10
|
||||
|
||||
## Kontext
|
||||
@@ -133,6 +133,30 @@ nicht für andere: fremde Blätter und Versuche antworten mit **404**, Entwürfe
|
||||
bekommt für `Paper`-Aufgaben ein Upload-Feld statt Texteingabe, `AttemptReview` zeigt das Foto neben Aufgabe und Musterlösung. Die KI-Prüfung (`AiPurpose.LearningImageGrading`) schreibt `Verdict`, `Grader = Ai`, `Reason` und eine Sicherheitsangabe
|
||||
(neue Spalte) in `LearningAnswer`; bestätigt wird weiterhin über `confirm`. Zeitfenster und Abgabe-Logik (`Submit`, `LearningExpiryService`) bleiben unverändert.
|
||||
|
||||
## Umsetzung Phase 10c (2026-10-10)
|
||||
|
||||
Phase 10c liefert die Papierlösung per Foto, die manuelle Prüfung und die optionale KI-Prüfung der Fotos. Migration `Phase10cPhotos`.
|
||||
|
||||
### Modell und Regeln
|
||||
* `LearningPhoto` (Bytes in der Datenbank wie ADR-0009/0019): je Versuch und Aufgabe höchstens ein Foto (erneutes Hochladen ersetzt), `ContentType` stammt aus der Signatur (PNG/JPEG/WebP, `LearningPhotoRules.Validate`), höchstens 5 MB.
|
||||
`LearningAnswer.Value` trägt bei Papieraufgaben mit Foto den Marker `Foto` (das Kind kann ihn über den Text-Endpunkt nicht setzen: 409); `LearningAnswer.PhotoPurgedAt` vermerkt ein nach der Frist gelöschtes Foto.
|
||||
* `Paper` ist freigeschaltet (`IsSupported`, `CheckSolution`): eine **Musterlösung ist Pflicht** (Maßstab für Eltern und KI). Ein Papier-Foto ist bis zur Prüfung `Open`, ohne Foto nach der Abgabe `Wrong` („Keine Antwort“); `Submit`, Zeitlimit und `LearningExpiryService` blieben unverändert.
|
||||
* Hochladen/Ersetzen/Löschen nur durch das Zielkind, nur bei laufendem Versuch; nach Frist plus Toleranz oder Abgabe 409 (mit automatischer Abgabe wie beim Speichern). Der Versuch wird dabei angefasst (`LastActivityAt`), damit ein Upload während der Abgabe per RowVersion kollidiert.
|
||||
* Abruf (`GET /api/learning/attempts/{id}/items/{itemId}/photo`): Eltern erst nach der Abgabe, das Kind nur das eigene; alle anderen 404. Antwort mit `Cache-Control: private, no-store`, `X-Content-Type-Options: nosniff`, restriktiver CSP; Typ nur aus der erkannten Signatur. Der Service Worker umgeht `/api/`.
|
||||
* **Aufbewahrung:** Familieneinstellung `LearningPhotoRetentionDays` (Standard 30, 0–365; **0 = sofort beim Bestätigen**). Gelöscht werden Fotos bestätigter Versuche nach Prüfzeitpunkt plus Frist sowie Fotos gelöschter Blätter – stündlich durch `LearningPhotoRetentionService`, zusätzlich beim Foto-Abruf und beim Öffnen der Prüfansicht (nie länger abrufbar als eingestellt). Unbestätigte Versuche behalten ihre Fotos. Die Antwort behält den Löschvermerk, die Prüfansicht zeigt „nach Ablauf der Aufbewahrungsfrist gelöscht“; Erneut-Öffnen bringt das Foto nicht zurück.
|
||||
* **KI-Prüfung** (`POST /api/learning/attempts/{id}/ai-grade`, `LearningManage` + `AiUse`): Eltern lösen sie je Versuch aus, mit bestätigtem Datenschutzhinweis (`AcknowledgePrivacy`, sonst 400). Voraussetzungen wie ADR-0020: ausdrücklich zugeordneter Anbieter für `LearningImageGrading` (kein Rückfall auf den Standard-Anbieter, sonst 409), Bilder-Opt-in außerhalb des Heimnetzes (`AiService`), Tageslimit (429). Ein Aufruf je Foto mit festem Prompt (Fach, Stufe, Aufgabe, Musterlösung, Foto; keine Namen; Text im Foto ist Daten, nie Anweisung) und geschlossenem JSON-Schema.
|
||||
Die Ausgabe wird streng geprüft (`LearningAiGrading.Parse`: alle Felder, keine zusätzlichen, Typen/Bereiche), Punkte folgen dem Urteil (richtig = Aufgabenpunkte, falsch = 0, Teilpunkte nur plausibel), Texte werden bereinigt und gekürzt. **Sicherheit unter 70 %, unleserlich, „unklar“ oder unplausible Teilpunkte bleiben `Open`.** Gespeichert werden `Verdict`, `Grader = Ai`, `Reason`, `AiConfidence` (neue Spalte). Elternbewertungen werden nie überschrieben; schon sicher bewertete Fotos werden bei erneutem Auslösen übersprungen (unsichere und nicht fertige werden fortgesetzt).
|
||||
Jeder Vorschlag wird sofort gespeichert (lange Aufrufe/Abbruch verlieren nichts); nach 120 s wird kein weiteres Foto begonnen. Der Proxy (`ActivityTimeout` 5 min) und der Browser-`HttpClient` (6 min) erlauben lange KI-Aufrufe. Bestätigt wird weiter über `confirm`; ein offener Vorschlag verhindert das Bestätigen.
|
||||
* **Kinder sehen nie KI-Details:** KI-Vorschläge zählen für das Kind als `Open`, bis Eltern bestätigt haben; Begründung und Sicherheit stehen in keinem Kinder-DTO (Mapper-Tests); die KI-Begründung wird in der Prüfansicht nicht als Elternkommentar vorbelegt (serverseitig wird eine unverändert zurückgesendete KI-Begründung verworfen).
|
||||
* UI: `PhotoUpload` im `AttemptRunner` (Kamera mit `capture`, Datei, Vorschau, Ersetzen/Entfernen; Fotos über 2 MB werden im Browser verkleinert, damit Handyfotos das 5-MB-Limit einhalten); `AttemptReview` zeigt das Foto neben der Musterlösung, Sicherheit der KI und den KI-Knopf (nur wenn `GET /api/ai/status` die Foto-Prüfung anbietet); Einstellung „Lernfotos aufbewahren“ in den Familieneinstellungen.
|
||||
|
||||
### Abweichungen und bewusste Vereinfachungen
|
||||
* Fotos gibt es **je Aufgabe**, nicht je Blatt (ein Blattfoto für mehrere Aufgaben ist nicht umgesetzt); je Aufgabe nur eins.
|
||||
* „KI-Bewertung gilt direkt“ ist **nicht** umgesetzt: Eltern bestätigen immer. Die Sicherheitsschwelle (70 %) ist fest.
|
||||
* Der Foto-Marker `Foto` im Antwortfeld ersetzt eine eigene Spalte; Aufbewahrung 0 bedeutet Löschen beim Bestätigen (ein erneut geöffneter Versuch hat dann kein Foto mehr).
|
||||
* Nicht bestätigte (z. B. liegengebliebene) Versuche behalten ihre Fotos unbegrenzt.
|
||||
* Die Prüfung der KI-Voraussetzung „Bilder-Opt-in fehlt“ ist wegen der Anbieter-Validierung (Cloud-Anbieter mit Foto-Zweck brauchen das Opt-in schon beim Speichern) im Endpunkt nur defensiv und per Unit-/`AiService`-Test abgedeckt, nicht per Integrationstest.
|
||||
|
||||
## Bewusst nicht Teil des ersten Schnitts
|
||||
Offline-Lösen, Sprachausgabe/Diktat, Lernstatistiken über Wochen, Adaptive Schwierigkeit, Formeleditor (Aufgaben bleiben Text), Auswertung mehrerer Kinder im Vergleich,
|
||||
Überwachung gegen Schummeln (die Emulation ist ein Übungsrahmen, keine Fernprüfung).
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Claims;
|
||||
using FamilyPlanner.Api.Features.Ai;
|
||||
using FamilyPlanner.Api.Infrastructure;
|
||||
@@ -23,9 +24,8 @@ internal static class LearningAiGradeEndpoints
|
||||
.RequirePermission(Permission.AiUse)
|
||||
.Validate<LearningAiGradeRequest>();
|
||||
|
||||
private sealed record Candidate(LearningItem Item, string ContentType, byte[] Data);
|
||||
|
||||
private sealed record Outcome(Guid ItemId, LearningAiDecision Decision);
|
||||
/// <summary>Nach dieser Zeit wird kein weiteres Foto mehr begonnen (Browser und Proxy warten höchstens ein paar Minuten).</summary>
|
||||
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(120);
|
||||
|
||||
private static async Task<IResult> GradeAsync(
|
||||
Guid attemptId,
|
||||
@@ -50,15 +50,15 @@ internal static class LearningAiGradeEndpoints
|
||||
: "Der Versuch wurde noch nicht abgegeben.");
|
||||
}
|
||||
|
||||
// Zu prüfen: Fotos ohne Elternbewertung; schon sicher von der KI bewertete Antworten bleiben (ein erneutes Auslösen setzt unterbrochene oder unsichere fort).
|
||||
var photoAnswers = LearningRules.ParseOrder(attempt.ItemOrder)
|
||||
.Select(id => (Item: set.Items.SingleOrDefault(i => i.Id == id), Answer: attempt.Answers.SingleOrDefault(a => a.ItemId == id)))
|
||||
.Where(x => x.Item is { AnswerType: LearningAnswerType.Paper } && x.Answer is not null && x.Answer.Grader != LearningGrader.Parent
|
||||
&& LearningPhotoRules.StateOf(LearningAnswerType.Paper, x.Answer.Value, x.Answer.PhotoPurgedAt) == LearningPhotoState.Available)
|
||||
.Where(x => x.Item is { AnswerType: LearningAnswerType.Paper } && x.Answer is not null && IsGradable(x.Answer))
|
||||
.Select(x => x.Item!)
|
||||
.ToList();
|
||||
if (photoAnswers.Count == 0)
|
||||
{
|
||||
return EndpointHelpers.Conflict("Es gibt keine Fotos, die die KI prüfen könnte (bereits von Eltern bewertet, gelöscht oder nicht hochgeladen).");
|
||||
return EndpointHelpers.Conflict("Es gibt keine Fotos, die die KI prüfen könnte (bereits bewertet, gelöscht oder nicht hochgeladen).");
|
||||
}
|
||||
|
||||
// Der Foto-Zweck hat bewusst keinen Rückfall auf den Standard-Anbieter (ADR-0020 Punkt 3).
|
||||
@@ -75,11 +75,18 @@ internal static class LearningAiGradeEndpoints
|
||||
}
|
||||
|
||||
var userId = user.UserId() ?? Guid.Empty;
|
||||
var outcomes = new List<Outcome>();
|
||||
var failed = 0;
|
||||
AiException? stopped = null;
|
||||
var clock = Stopwatch.StartNew();
|
||||
var (applied, needsReview, failed, processed) = (0, 0, 0, 0);
|
||||
string? stopMessage = null;
|
||||
foreach (var item in photoAnswers)
|
||||
{
|
||||
if (clock.Elapsed > Budget)
|
||||
{
|
||||
stopMessage = "Die Prüfung dauerte zu lange und wurde unterbrochen. Die bisherigen Vorschläge sind gespeichert – bitte erneut auslösen, um die übrigen Fotos zu prüfen.";
|
||||
break;
|
||||
}
|
||||
|
||||
processed++;
|
||||
var photo = await db.LearningPhotos.AsNoTracking().SingleOrDefaultAsync(p => p.AttemptId == attemptId && p.ItemId == item.Id, ct);
|
||||
if (photo is null)
|
||||
{
|
||||
@@ -87,88 +94,107 @@ internal static class LearningAiGradeEndpoints
|
||||
continue;
|
||||
}
|
||||
|
||||
LearningAiDecision? decision = null;
|
||||
try
|
||||
{
|
||||
var result = await ai.RunWithAsync(
|
||||
userId, provider, AiPurpose.LearningImageGrading, LearningImageGradingPrompt.Build(set, item, photo.ContentType, photo.Data), ct);
|
||||
if (LearningAiGrading.Parse(result.Response.Json) is { } parsed)
|
||||
{
|
||||
outcomes.Add(new Outcome(item.Id, LearningAiGrading.Decide(parsed, item.Points)));
|
||||
}
|
||||
else
|
||||
{
|
||||
failed++; // Schema verletzt: nichts übernehmen
|
||||
}
|
||||
decision = LearningAiGrading.Parse(result.Response.Json) is { } parsed ? LearningAiGrading.Decide(parsed, item.Points) : null; // Schema verletzt: nichts übernehmen
|
||||
}
|
||||
catch (AiException ex) when (ex.Kind == AiErrorKind.BadResponse)
|
||||
{
|
||||
failed++;
|
||||
// unbrauchbare Antwort: diese Aufgabe bleibt unverändert
|
||||
}
|
||||
catch (AiException ex)
|
||||
{
|
||||
stopped = ex; // Konfiguration, Limit, Erreichbarkeit: weitere Aufrufe wären zwecklos
|
||||
failed += photoAnswers.Count - outcomes.Count - failed;
|
||||
// Konfiguration, Limit, Erreichbarkeit: weitere Aufrufe wären zwecklos.
|
||||
if (applied == 0)
|
||||
{
|
||||
return TypedResults.Problem(statusCode: ex.HttpStatus, title: "KI-Prüfung nicht möglich", detail: ex.Message);
|
||||
}
|
||||
|
||||
stopMessage = $"Die Prüfung wurde abgebrochen: {ex.Message}";
|
||||
failed++; // die laufende Aufgabe
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break; // Browser hat aufgegeben: bereits gespeicherte Vorschläge bleiben
|
||||
}
|
||||
|
||||
// Jeden Vorschlag sofort speichern (die Aufrufe können lange dauern; ein Abbruch darf bezahlte Ergebnisse nicht verlieren), unabhängig vom Abbruch der Anfrage.
|
||||
if (decision is not null && await SaveDecisionAsync(db, service, attemptId, item.Id, decision))
|
||||
{
|
||||
applied++;
|
||||
if (decision.Verdict == LearningVerdict.Open)
|
||||
{
|
||||
needsReview++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (outcomes.Count == 0)
|
||||
if (applied == 0 && stopMessage is null)
|
||||
{
|
||||
return stopped is not null
|
||||
? TypedResults.Problem(statusCode: stopped.HttpStatus, title: "KI-Prüfung nicht möglich", detail: stopped.Message)
|
||||
: TypedResults.Problem(statusCode: StatusCodes.Status502BadGateway, title: "KI-Prüfung nicht möglich",
|
||||
detail: "Die KI hat keine auswertbare Antwort geliefert. Bitte selbst prüfen oder später erneut versuchen.");
|
||||
return TypedResults.Problem(statusCode: StatusCodes.Status502BadGateway, title: "KI-Prüfung nicht möglich",
|
||||
detail: "Die KI hat keine auswertbare Antwort geliefert. Bitte selbst prüfen oder später erneut versuchen.");
|
||||
}
|
||||
|
||||
// Ergebnis anwenden: neu laden (der Aufruf dauerte), nichts überschreiben, was Eltern inzwischen bewertet haben.
|
||||
failed += photoAnswers.Count - processed; // nicht mehr begonnen (Zeitbudget, Abbruch)
|
||||
db.ChangeTracker.Clear();
|
||||
var fresh = await LoadAsync(db, attemptId, tracking: true, ct);
|
||||
if (fresh is null || fresh.Attempt.Status != LearningAttemptStatus.Submitted)
|
||||
var fresh = await LoadAsync(db, attemptId, tracking: false, CancellationToken.None);
|
||||
if (fresh is null)
|
||||
{
|
||||
return EndpointHelpers.Conflict("Der Versuch wurde inzwischen geändert. Bitte neu laden und erneut prüfen.");
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var names = await LearningAccess.DisplayNamesAsync(db, [fresh.Attempt.UserId], CancellationToken.None);
|
||||
var credited = await db.PointsLedger.AsNoTracking().Where(p => p.LearningSetId == fresh.Set.Id).SumAsync(p => (int?)p.Points, CancellationToken.None) ?? 0;
|
||||
var dto = LearningMapper.ToReviewDto(fresh.Attempt, fresh.Set, names.GetValueOrDefault(fresh.Attempt.UserId, string.Empty), credited);
|
||||
return TypedResults.Ok(new LearningAiGradeResultDto(applied, needsReview, failed, stopMessage, dto));
|
||||
}
|
||||
|
||||
/// <summary>Gradable: nicht von Eltern bewertet, Foto vorhanden und nicht schon sicher von der KI bewertet.</summary>
|
||||
private static bool IsGradable(LearningAnswer answer) =>
|
||||
answer.Grader != LearningGrader.Parent
|
||||
&& !(answer.Grader == LearningGrader.Ai && answer.Verdict != LearningVerdict.Open)
|
||||
&& LearningPhotoRules.StateOf(LearningAnswerType.Paper, answer.Value, answer.PhotoPurgedAt) == LearningPhotoState.Available;
|
||||
|
||||
/// <summary>
|
||||
/// Speichert einen Vorschlag; überschreibt nichts, was Eltern inzwischen bewertet haben, und nur bei noch nicht bestätigtem Versuch.
|
||||
/// Liefert <c>false</c>, wenn die Antwort nicht mehr übernommen werden durfte oder ein Konflikt auftrat.
|
||||
/// </summary>
|
||||
private static async Task<bool> SaveDecisionAsync(AppDbContext db, LearningAttemptService service, Guid attemptId, Guid itemId, LearningAiDecision decision)
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var fresh = await LoadAsync(db, attemptId, tracking: true, CancellationToken.None);
|
||||
var answer = fresh?.Attempt.Answers.SingleOrDefault(a => a.ItemId == itemId);
|
||||
if (fresh is null || fresh.Attempt.Status != LearningAttemptStatus.Submitted || answer is null || !IsGradable(answer))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = service.Now;
|
||||
var applied = 0;
|
||||
var needsReview = 0;
|
||||
foreach (var outcome in outcomes)
|
||||
{
|
||||
var answer = fresh.Attempt.Answers.SingleOrDefault(a => a.ItemId == outcome.ItemId);
|
||||
if (answer is null || answer.Grader == LearningGrader.Parent
|
||||
|| LearningPhotoRules.StateOf(LearningAnswerType.Paper, answer.Value, answer.PhotoPurgedAt) != LearningPhotoState.Available)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
answer.Verdict = outcome.Decision.Verdict;
|
||||
answer.AwardedPoints = outcome.Decision.AwardedPoints;
|
||||
answer.Grader = LearningGrader.Ai;
|
||||
answer.AiConfidence = outcome.Decision.Confidence;
|
||||
answer.Reason = outcome.Decision.Reason;
|
||||
answer.GradedAt = now;
|
||||
applied++;
|
||||
if (outcome.Decision.Verdict == LearningVerdict.Open)
|
||||
{
|
||||
needsReview++;
|
||||
}
|
||||
}
|
||||
|
||||
answer.Verdict = decision.Verdict;
|
||||
answer.AwardedPoints = decision.AwardedPoints;
|
||||
answer.Grader = LearningGrader.Ai;
|
||||
answer.AiConfidence = decision.Confidence;
|
||||
answer.Reason = decision.Reason;
|
||||
answer.GradedAt = now;
|
||||
fresh.Attempt.LastActivityAt = now;
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return true;
|
||||
}
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
service.LogConflict(ex, "KI-Prüfung speichern");
|
||||
return EndpointHelpers.Conflict("Der Versuch wurde inzwischen geändert. Bitte neu laden und erneut prüfen.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var names = await LearningAccess.DisplayNamesAsync(db, [fresh.Attempt.UserId], ct);
|
||||
var credited = await db.PointsLedger.AsNoTracking().Where(p => p.LearningSetId == fresh.Set.Id).SumAsync(p => (int?)p.Points, ct) ?? 0;
|
||||
var dto = LearningMapper.ToReviewDto(fresh.Attempt, fresh.Set, names.GetValueOrDefault(fresh.Attempt.UserId, string.Empty), credited);
|
||||
var message = stopped is not null ? $"Die Prüfung wurde abgebrochen: {stopped.Message}" : null;
|
||||
return TypedResults.Ok(new LearningAiGradeResultDto(applied, needsReview, failed, message, dto));
|
||||
}
|
||||
|
||||
private sealed record Loaded(LearningAttempt Attempt, LearningSet Set);
|
||||
|
||||
@@ -151,8 +151,6 @@ public static class LearningAiGrading
|
||||
private static string Limit(string text)
|
||||
{
|
||||
text = text.Trim();
|
||||
return "KI: ".Length + text.Length <= LearningAnswer.ReasonMaxLength
|
||||
? "KI: " + text
|
||||
: "KI: " + text[..(LearningAnswer.ReasonMaxLength - "KI: ".Length - 1)] + "…";
|
||||
return text.Length <= LearningAnswer.ReasonMaxLength ? text : text[..(LearningAnswer.ReasonMaxLength - 1)] + "…";
|
||||
}
|
||||
}
|
||||
@@ -326,10 +326,7 @@
|
||||
else
|
||||
{
|
||||
_error = result.Problem?.Message ?? "Die KI-Prüfung hat nicht geklappt.";
|
||||
if (result.Problem?.Status == 409)
|
||||
{
|
||||
await ReloadQuietAsync();
|
||||
}
|
||||
await ReloadQuietAsync(); // bereits gespeicherte Vorschläge anzeigen (jeder Vorschlag wird sofort gespeichert)
|
||||
}
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -21,7 +21,12 @@ builder.Services.AddMudServices(config =>
|
||||
});
|
||||
|
||||
builder.Services.AddTransient<CsrfHeaderHandler>();
|
||||
builder.Services.AddHttpClient(ApiClient.Name, client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
|
||||
// Zeitlimit großzügig: KI-Aufrufe der Eltern (Fotos prüfen) können Minuten dauern; normale Aufrufe sind davon nicht betroffen.
|
||||
builder.Services.AddHttpClient(ApiClient.Name, client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress);
|
||||
client.Timeout = TimeSpan.FromMinutes(6);
|
||||
})
|
||||
.AddHttpMessageHandler<CsrfHeaderHandler>();
|
||||
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient(ApiClient.Name));
|
||||
|
||||
|
||||
@@ -129,8 +129,22 @@ public sealed class LearningApi(HttpClient http) : ILearningApi
|
||||
public Task<ApiResult<List<UserDirectoryEntry>>> GetUsersAsync(CancellationToken ct = default) =>
|
||||
Send<List<UserDirectoryEntry>>(() => http.GetAsync("api/users", ct), ct);
|
||||
|
||||
public Task<ApiResult<LearningAiGradeResultDto>> AiGradeAsync(Guid attemptId, LearningAiGradeRequest request, CancellationToken ct = default) =>
|
||||
Send<LearningAiGradeResultDto>(() => http.PostAsJsonAsync($"{Base}/attempts/{attemptId}/ai-grade", request, ct), ct);
|
||||
public async Task<ApiResult<LearningAiGradeResultDto>> AiGradeAsync(Guid attemptId, LearningAiGradeRequest request, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await Send<LearningAiGradeResultDto>(() => http.PostAsJsonAsync($"{Base}/attempts/{attemptId}/ai-grade", request, ct), ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// Zeitlimit des Browsers: der Server speichert jeden Vorschlag sofort, die Ansicht lädt den Stand neu.
|
||||
return ApiResult<LearningAiGradeResultDto>.Fail(new ApiProblem
|
||||
{
|
||||
Status = 504,
|
||||
Detail = "Die KI-Prüfung dauert zu lange. Bereits fertige Vorschläge sind gespeichert – bitte erneut auslösen, um den Rest zu prüfen.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ApiResult<List<MyLearningSetDto>>> GetMineAsync(CancellationToken ct = default) =>
|
||||
Send<List<MyLearningSetDto>>(() => http.GetAsync($"{Base}/my", ct), ct);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Yarp.ReverseProxy.Configuration;
|
||||
using Yarp.ReverseProxy.Forwarder;
|
||||
|
||||
namespace FamilyPlanner.Web;
|
||||
|
||||
@@ -25,6 +26,9 @@ internal static class BffSetup
|
||||
new()
|
||||
{
|
||||
ClusterId = ApiCluster,
|
||||
|
||||
// KI-Aufrufe (Fotos prüfen, Aufgaben erzeugen) dauern oft länger als die Standardfrist von 100 s ohne Datenfluss.
|
||||
HttpRequest = new ForwarderRequestConfig { ActivityTimeout = TimeSpan.FromMinutes(5) },
|
||||
Destinations = new Dictionary<string, DestinationConfig>
|
||||
{
|
||||
["api"] = new() { Address = "https+http://api" },
|
||||
|
||||
@@ -167,7 +167,7 @@ public sealed class LearningPhotoAiTests(AppFixture fixture)
|
||||
|
||||
var row = result.Attempt.Answers.Single(a => a.ItemId == papers[0]);
|
||||
Assert.Equal((LearningVerdict.Correct, LearningGrader.Ai, 92, 3), (row.Verdict, row.Grader, row.AiConfidence, row.AwardedPoints));
|
||||
Assert.StartsWith("KI: ", row.Reason);
|
||||
Assert.StartsWith("Die Rechnung stimmt", row.Reason);
|
||||
Assert.Contains("Ergebnis 43", row.Reason);
|
||||
Assert.Equal(0, result.Attempt.OpenCount);
|
||||
|
||||
@@ -326,13 +326,46 @@ public sealed class LearningPhotoAiTests(AppFixture fixture)
|
||||
Assert.Equal(LearningVerdict.Open, (await GetReviewAsync(parent, attempt.Id)).Answers.Single(a => a.ItemId == papers[0]).Verdict);
|
||||
|
||||
// Tageslimit: zwei Aufrufe sind erlaubt (der Fehlschlag zählt mit), der dritte wird mit 429 abgelehnt
|
||||
server.Respond = _ => (200, Reply("correct", 90), TimeSpan.Zero);
|
||||
server.Respond = _ => (200, Reply("correct", 40), TimeSpan.Zero); // unsicher: bleibt offen und damit prüfbar
|
||||
Assert.Equal(HttpStatusCode.OK, (await AiGradeAsync(parent, attempt.Id)).StatusCode);
|
||||
var limited = await AiGradeAsync(parent, attempt.Id);
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, limited.StatusCode);
|
||||
Assert.Contains("Tageslimit", await limited.Content.ReadAsStringAsync(Ct));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Every_suggestion_is_saved_immediately_and_a_second_run_continues_with_the_rest()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
await ClearProvidersAsync(admin);
|
||||
await using var server = FakeAiServer.Start();
|
||||
await CreateProviderAsync(admin, server.BaseUrl, AiPurpose.LearningImageGrading);
|
||||
var (_, parent, _, attempt, papers) = await SubmittedPaperAsync(photos: 2, paperItems: 2);
|
||||
|
||||
// Erster Lauf: die erste Aufgabe gelingt, danach fällt der Anbieter aus → Teilergebnis bleibt erhalten
|
||||
var calls = 0;
|
||||
server.Respond = _ => Interlocked.Increment(ref calls) == 1 ? (200, Reply("correct", 95), TimeSpan.Zero) : (500, "{}", TimeSpan.Zero);
|
||||
var partial = await AiGradeOkAsync(parent, attempt.Id);
|
||||
Assert.Equal((1, 0, 1), (partial.Graded, partial.NeedsReview, partial.Failed));
|
||||
Assert.Contains("abgebrochen", partial.Message);
|
||||
var answers = (await GetReviewAsync(parent, attempt.Id)).Answers.ToDictionary(a => a.ItemId);
|
||||
Assert.Equal((LearningVerdict.Correct, LearningGrader.Ai), (answers[papers[0]].Verdict, answers[papers[0]].Grader));
|
||||
Assert.Equal(LearningGrader.Auto, answers[papers[1]].Grader);
|
||||
|
||||
// Zweiter Lauf: die schon sicher bewertete Aufgabe wird nicht noch einmal gesendet
|
||||
server.Respond = _ => (200, Reply("wrong", 88), TimeSpan.Zero);
|
||||
var requestsBefore = server.Requests.Count;
|
||||
var second = await AiGradeOkAsync(parent, attempt.Id);
|
||||
Assert.Equal((1, 0, 0), (second.Graded, second.NeedsReview, second.Failed));
|
||||
Assert.Equal(requestsBefore + 1, server.Requests.Count);
|
||||
var after = second.Attempt.Answers.ToDictionary(a => a.ItemId);
|
||||
Assert.Equal(LearningVerdict.Correct, after[papers[0]].Verdict);
|
||||
Assert.Equal(LearningVerdict.Wrong, after[papers[1]].Verdict);
|
||||
|
||||
// Alles bewertet: ein weiterer Lauf hat nichts mehr zu tun
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await AiGradeAsync(parent, attempt.Id)).StatusCode);
|
||||
}
|
||||
|
||||
// ---------- Aufbewahrung ----------
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -74,7 +74,7 @@ public sealed class LearningAiGradingTests
|
||||
{
|
||||
var correct = LearningAiGrading.Decide(Parsed(Reply("correct", 0, 95)), 3);
|
||||
Assert.Equal((LearningVerdict.Correct, 3, 95), (correct.Verdict, correct.AwardedPoints, correct.Confidence));
|
||||
Assert.StartsWith("KI: ", correct.Reason);
|
||||
Assert.Contains("Rechnung stimmt", correct.Reason);
|
||||
|
||||
var wrong = LearningAiGrading.Decide(Parsed(Reply("wrong", 2, 80)), 3);
|
||||
Assert.Equal((LearningVerdict.Wrong, 0, 80), (wrong.Verdict, wrong.AwardedPoints, wrong.Confidence)); // Punkte folgen dem Urteil, nicht der KI-Angabe
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
using FamilyPlanner.Api.Features.Learning;
|
||||
using FamilyPlanner.Domain.Learning;
|
||||
using FamilyPlanner.Infrastructure.Persistence;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace FamilyPlanner.UnitTests.Learning;
|
||||
|
||||
/// <summary>Löschdienst der Lernfotos: Stichtag, unbestätigte Versuche, gelöschte Blätter und der Löschvermerk an der Antwort (gegen SQLite im Speicher).</summary>
|
||||
public sealed class LearningPhotoRetentionTests : IDisposable
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 10, 12, 8, 0, 0, TimeSpan.Zero);
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private readonly SqliteConnection _connection = new("DataSource=:memory:");
|
||||
|
||||
public LearningPhotoRetentionTests()
|
||||
{
|
||||
_connection.Open();
|
||||
using var db = CreateContext();
|
||||
db.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose() => _connection.Dispose();
|
||||
|
||||
private AppDbContext CreateContext() =>
|
||||
new(new DbContextOptionsBuilder<AppDbContext>().UseSqlite(_connection).ReplaceService<IModelCustomizer, NoRowVersionCustomizer>().Options);
|
||||
|
||||
/// <summary>
|
||||
/// SQLite kennt weder <c>rowversion</c> noch Vergleiche mit <see cref="DateTimeOffset"/>: für den Test werden die Versionsspalten zu gewöhnlichen Spalten
|
||||
/// und Zeitpunkte als Zahl gespeichert (die Löschlogik braucht die Versionen nicht; die Abfrage bleibt dieselbe).
|
||||
/// </summary>
|
||||
private sealed class NoRowVersionCustomizer(ModelCustomizerDependencies dependencies) : ModelCustomizer(dependencies)
|
||||
{
|
||||
public override void Customize(ModelBuilder modelBuilder, DbContext context)
|
||||
{
|
||||
base.Customize(modelBuilder, context);
|
||||
foreach (var entity in modelBuilder.Model.GetEntityTypes().Where(e => !e.IsOwned() && e.FindPrimaryKey() is not null && e.ClrType.Namespace?.StartsWith("FamilyPlanner", StringComparison.Ordinal) == true).ToList())
|
||||
{
|
||||
foreach (var property in entity.GetProperties().Where(p => p.ClrType == typeof(DateTimeOffset) || p.ClrType == typeof(DateTimeOffset?)).ToList())
|
||||
{
|
||||
modelBuilder.Entity(entity.ClrType).Property(property.Name).HasConversion(new DateTimeOffsetToBinaryConverter());
|
||||
}
|
||||
|
||||
foreach (var property in entity.GetProperties().Where(p => p.ClrType == typeof(byte[]) && p.IsConcurrencyToken).ToList())
|
||||
{
|
||||
modelBuilder.Entity(entity.ClrType).Property(property.Name).IsConcurrencyToken(false).ValueGeneratedNever();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Legt Blatt, Versuch, Antwort und Foto an; liefert Versuchs- und Aufgaben-Id.</summary>
|
||||
private async Task<(Guid Attempt, Guid Item)> SeedAsync(
|
||||
LearningAttemptStatus status, DateTimeOffset? reviewedAt, bool setDeleted = false)
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var set = new LearningSet { Title = "Heft", TargetUserId = Guid.NewGuid(), Status = LearningSetStatus.Submitted, IsDeleted = setDeleted };
|
||||
var item = new LearningItem { SetId = set.Id, Question = "Papier", AnswerType = LearningAnswerType.Paper, CorrectAnswer = "43", Points = 3 };
|
||||
var attempt = new LearningAttempt
|
||||
{
|
||||
SetId = set.Id,
|
||||
UserId = set.TargetUserId,
|
||||
Number = 1,
|
||||
Status = status,
|
||||
StartedAt = Now.AddDays(-40),
|
||||
ReviewedAt = reviewedAt,
|
||||
ItemOrder = item.Id.ToString(),
|
||||
};
|
||||
db.LearningSets.Add(set);
|
||||
db.LearningItems.Add(item);
|
||||
db.LearningAttempts.Add(attempt);
|
||||
db.LearningAnswers.Add(new LearningAnswer { AttemptId = attempt.Id, ItemId = item.Id, Value = LearningPhotoRules.AnswerMarker, AnsweredAt = Now.AddDays(-40) });
|
||||
db.LearningPhotos.Add(new LearningPhoto { AttemptId = attempt.Id, ItemId = item.Id, ContentType = "image/png", Data = [1, 2, 3] });
|
||||
await db.SaveChangesAsync(Ct);
|
||||
return (attempt.Id, item.Id);
|
||||
}
|
||||
|
||||
private async Task<(bool PhotoExists, DateTimeOffset? PurgedAt)> StateAsync(Guid attempt, Guid item)
|
||||
{
|
||||
await using var db = CreateContext();
|
||||
var exists = await db.LearningPhotos.AnyAsync(p => p.AttemptId == attempt && p.ItemId == item, Ct);
|
||||
var purged = (await db.LearningAnswers.SingleAsync(a => a.AttemptId == attempt && a.ItemId == item, Ct)).PhotoPurgedAt;
|
||||
return (exists, purged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Photos_are_deleted_once_the_retention_period_after_the_confirmation_has_passed()
|
||||
{
|
||||
var old = await SeedAsync(LearningAttemptStatus.Reviewed, Now.AddDays(-31));
|
||||
var border = await SeedAsync(LearningAttemptStatus.Reviewed, Now.AddDays(-30));
|
||||
var recent = await SeedAsync(LearningAttemptStatus.Reviewed, Now.AddDays(-29));
|
||||
|
||||
await using var db = CreateContext();
|
||||
var purged = await LearningPhotoRetentionService.PurgeAsync(db, Now, 30, Ct);
|
||||
|
||||
Assert.Equal(2, purged);
|
||||
Assert.Equal((false, Now), await StateAsync(old.Attempt, old.Item));
|
||||
Assert.Equal((false, Now), await StateAsync(border.Attempt, border.Item));
|
||||
Assert.Equal((true, (DateTimeOffset?)null), await StateAsync(recent.Attempt, recent.Item));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unconfirmed_attempts_keep_their_photos_no_matter_how_old()
|
||||
{
|
||||
var submitted = await SeedAsync(LearningAttemptStatus.Submitted, null);
|
||||
var inProgress = await SeedAsync(LearningAttemptStatus.InProgress, null);
|
||||
|
||||
await using var db = CreateContext();
|
||||
|
||||
Assert.Equal(0, await LearningPhotoRetentionService.PurgeAsync(db, Now, 0, Ct));
|
||||
Assert.Equal((true, (DateTimeOffset?)null), await StateAsync(submitted.Attempt, submitted.Item));
|
||||
Assert.Equal((true, (DateTimeOffset?)null), await StateAsync(inProgress.Attempt, inProgress.Item));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Photos_of_deleted_sheets_disappear_regardless_of_status_and_a_second_run_does_nothing()
|
||||
{
|
||||
var deleted = await SeedAsync(LearningAttemptStatus.Submitted, null, setDeleted: true);
|
||||
var kept = await SeedAsync(LearningAttemptStatus.Submitted, null);
|
||||
|
||||
await using var db = CreateContext();
|
||||
Assert.Equal(1, await LearningPhotoRetentionService.PurgeAsync(db, Now, 30, Ct));
|
||||
Assert.Equal(0, await LearningPhotoRetentionService.PurgeAsync(db, Now, 30, Ct)); // idempotent
|
||||
|
||||
Assert.Equal((false, Now), await StateAsync(deleted.Attempt, deleted.Item));
|
||||
Assert.Equal((true, (DateTimeOffset?)null), await StateAsync(kept.Attempt, kept.Item));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_retention_of_zero_days_purges_confirmed_attempts_immediately()
|
||||
{
|
||||
var confirmed = await SeedAsync(LearningAttemptStatus.Reviewed, Now);
|
||||
|
||||
await using var db = CreateContext();
|
||||
|
||||
Assert.Equal(1, await LearningPhotoRetentionService.PurgeAsync(db, Now, 0, Ct));
|
||||
Assert.Equal((false, Now), await StateAsync(confirmed.Attempt, confirmed.Item));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user