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:
JannisandClaude Sonnet 5.5 committed 2026-10-10 15:15:41 +02:00
1 parent b3d0546d6b
commit a6bc071efc
12 files changed
+322 -79

No files matched your search

+2 -2
View File
@@ -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
View File
@@ -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)
+25 -1
View File
@@ -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
+6 -1
View File
@@ -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);
+4
View File
@@ -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));
}
}