feat: photo upload for paper tasks, photo review and AI photo grading UI

PhotoUpload component (camera and file picker, preview, replace/remove), photo next to task and
model solution in AttemptReview, parent-triggered AI grading button visible only when the status
offers photo grading, retention setting in the admin settings editor, bUnit coverage.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 14:44:40 +02:00
1 parent 225f02a18b
commit 32091a825e
12 files changed
+776 -8

No files matched your search

@@ -48,6 +48,9 @@ else
<MudText Typo="Typo.subtitle2">Aufgaben &amp; Ämtchen</MudText>
<MudSwitch T="bool" @bind-Value="_model.ChorePointsEnabled" Label="Punktesystem für Ämtchen" Color="Color.Primary" />
<MudSwitch T="bool" @bind-Value="_model.ChildTasksRequireApproval" Label="Erledigte Kinder-Aufgaben von Eltern bestätigen lassen" Color="Color.Primary" />
<MudText Typo="Typo.subtitle2" Class="mt-3">Lernaufgaben</MudText>
<MudNumericField T="int" @bind-Value="_model.LearningPhotoRetentionDays" Label="Lernfotos aufbewahren (Tage nach der Prüfung)"
Min="1" Max="365" Variant="Variant.Outlined" Margin="Margin.Dense" id="learning-photo-retention" />
<MudText Typo="Typo.subtitle2" Class="mt-3">Gäste</MudText>
<MudSwitch T="bool" @bind-Value="_model.GuestsCanEditShoppingList" Label="Gäste dürfen die Einkaufsliste ergänzen/abhaken" Color="Color.Primary" />
<MudSwitch T="bool" @bind-Value="_model.GuestChatEnabled" Label="Gäste dürfen den Familienchat nutzen" Color="Color.Primary" />
@@ -133,6 +136,8 @@ else
public bool GuestChatEnabled { get; set; }
public int LearningPhotoRetentionDays { get; set; } = 30;
public static SettingsModel From(FamilySettingsDto s) => new()
{
FamilyName = s.FamilyName,
@@ -144,10 +149,11 @@ else
ChildTasksRequireApproval = s.ChildTasksRequireApproval,
GuestsCanEditShoppingList = s.GuestsCanEditShoppingList,
GuestChatEnabled = s.GuestChatEnabled,
LearningPhotoRetentionDays = s.LearningPhotoRetentionDays,
};
public FamilySettingsDto ToDto() => new(
FamilyName.Trim(), TimeZoneId, MealPlanDays, MealTypes, TaskCategories,
ChorePointsEnabled, ChildTasksRequireApproval, GuestsCanEditShoppingList, GuestChatEnabled);
ChorePointsEnabled, ChildTasksRequireApproval, GuestsCanEditShoppingList, GuestChatEnabled, LearningPhotoRetentionDays);
}
}
@@ -3,6 +3,8 @@
@implements IDisposable
@inject ILearningApi Api
@inject ILiveConnection Live
@inject IAiApi Ai
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@* Eltern prüfen einen abgegebenen Versuch: Freitext bewerten, Automatik korrigieren, Teilpunkte und Begründung, Bestätigen oder erneut öffnen. *@
@@ -68,6 +70,19 @@
</div>
</MudPaper>
@if (CanAiGrade(review))
{
<MudPaper Outlined="true" Class="pa-3 mb-3 fp-review-ai">
<MudText Typo="Typo.subtitle2" HtmlTag="h2">KI-Vorschlag für die Fotos</MudText>
<MudText Typo="Typo.body2" Class="mb-2">
Die KI liest die Fotos und schlägt eine Bewertung mit Sicherheitsangabe vor. Bei geringer Sicherheit bleibt die Aufgabe offen. Sie bestätigen das Ergebnis immer selbst.
</MudText>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.AutoAwesome" OnClick="AiGradeAsync" Disabled="_busy" Class="fp-review-ai-button">
@(_busy && _aiRunning ? "KI prüft …" : "Fotos von der KI prüfen lassen")
</MudButton>
</MudPaper>
}
<ol class="fp-review-answers">
@foreach (var answer in review.Answers)
{
@@ -83,14 +98,58 @@
</MudChip>
</div>
<p class="fp-learning-question">@item.Question</p>
<p class="fp-review-given">Antwort: <strong>@LearningFormat.AnswerText(item.AnswerType, item.Options, item.Answer)</strong></p>
<p class="fp-review-correct">Musterlösung: <strong>@LearningFormat.AnswerText(item.AnswerType, item.Options, item.CorrectAnswer)</strong></p>
@if (item.AnswerType == LearningAnswerType.Paper)
{
<div class="fp-review-paper">
<div class="fp-review-photo" data-photo-state="@item.PhotoState.ToString().ToLowerInvariant()">
@switch (item.PhotoState)
{
case LearningPhotoState.Available:
<a href="@LearningPhotoUrls.Photo(review.AttemptId, item.ItemId, item.PhotoAt)" target="_blank" rel="noopener" aria-label="Foto in voller Größe öffnen">
<img class="fp-review-photo-img" src="@LearningPhotoUrls.Photo(review.AttemptId, item.ItemId, item.PhotoAt)"
alt="@($"Foto der Lösung von {review.ChildName} zu Aufgabe {item.Position + 1}")" loading="lazy" />
</a>
break;
case LearningPhotoState.Purged:
<MudAlert Severity="Severity.Info" Dense="true" Class="fp-review-photo-purged">Das Foto wurde nach Ablauf der Aufbewahrungsfrist gelöscht.</MudAlert>
break;
default:
<MudAlert Severity="Severity.Warning" Dense="true" Class="fp-review-photo-missing">Es wurde kein Foto abgegeben.</MudAlert>
break;
}
</div>
<div class="fp-review-paper-solution">
<p class="fp-review-correct">Musterlösung: <strong>@item.CorrectAnswer</strong></p>
</div>
</div>
}
else
{
<p class="fp-review-given">Antwort: <strong>@LearningFormat.AnswerText(item.AnswerType, item.Options, item.Answer)</strong></p>
<p class="fp-review-correct">Musterlösung: <strong>@LearningFormat.AnswerText(item.AnswerType, item.Options, item.CorrectAnswer)</strong></p>
}
@if (!string.IsNullOrWhiteSpace(item.Explanation))
{
<p class="fp-learning-explanation">@item.Explanation</p>
}
<p class="fp-review-grader">@LearningFormat.Grader(item.Grader)@(string.IsNullOrWhiteSpace(item.Reason) ? string.Empty : $": {item.Reason}")</p>
@if (item.Grader == LearningGrader.Ai && item.AiConfidence is { } confidence)
{
<div class="fp-review-confidence">
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="@(confidence >= LearningAiGrading.MinConfidence ? Color.Success : Color.Warning)">
Sicherheit der KI: @confidence %
</MudChip>
@if (item.Verdict == LearningVerdict.Open)
{
<span>Die KI ist unsicher – bitte selbst prüfen.</span>
}
else if (!confirmed)
{
<span>Nur ein Vorschlag – bitte prüfen und bestätigen.</span>
}
</div>
}
@if (!confirmed)
{
@@ -157,6 +216,8 @@
private bool _missing;
private string? _error;
private string? _notice;
private bool _aiAvailable;
private bool _aiRunning;
[Parameter, EditorRequired]
public Guid AttemptId { get; set; }
@@ -169,6 +230,7 @@
}
_loadedId = AttemptId;
await LoadAiStatusAsync();
Live.LearningChanged -= OnLearningChanged;
Live.LearningChanged += OnLearningChanged;
await LoadAsync();
@@ -212,13 +274,78 @@
}
}
/// <summary>Der Knopf erscheint nur, wenn die Foto-Prüfung eingerichtet ist (Anbieter für diesen Zweck, Bilder erlaubt) – GET api/ai/status.</summary>
private async Task LoadAiStatusAsync()
{
var status = await Ai.GetStatusAsync();
_aiAvailable = status.Succeeded && status.Value is { CanGradeImages: true };
}
private bool CanAiGrade(LearningAttemptReviewDto review) =>
_aiAvailable
&& review.Status == LearningAttemptStatus.Submitted
&& review.Answers.Any(a => a.AnswerType == LearningAnswerType.Paper && a.PhotoState == LearningPhotoState.Available && a.Grader != LearningGrader.Parent);
private async Task AiGradeAsync()
{
if (_busy)
{
return;
}
var confirmed = await DialogService.ShowMessageBoxAsync(
"Fotos von der KI prüfen lassen?",
"Die Fotos dieses Versuchs werden zur Prüfung an den eingerichteten KI-Anbieter gesendet. Liegt der Anbieter außerhalb Ihres Heimnetzes, verlassen die Fotos der Kinderhefte das Heimnetz. "
+ "Die KI liefert nur einen Vorschlag; bei Zweifeln bleibt die Aufgabe offen. Bewertungen, die Sie selbst vergeben haben, bleiben unverändert.",
yesText: "Ja, prüfen lassen", cancelText: "Abbrechen");
if (confirmed != true)
{
return;
}
_busy = true;
_aiRunning = true;
_error = null;
try
{
var result = await Api.AiGradeAsync(AttemptId, new LearningAiGradeRequest(true));
if (result.Succeeded && result.Value is { } graded)
{
_review = graded.Attempt;
_reasons.Clear();
Snackbar.Add(
graded.NeedsReview > 0
? $"KI-Vorschlag für {graded.Graded} Aufgabe(n) – {graded.NeedsReview} davon unsicher und weiter offen."
: $"KI-Vorschlag für {graded.Graded} Aufgabe(n) liegt vor.",
Severity.Info);
if (graded.Message is not null || graded.Failed > 0)
{
_error = graded.Message ?? $"{graded.Failed} Foto(s) konnten nicht ausgewertet werden – bitte selbst prüfen.";
}
}
else
{
_error = result.Problem?.Message ?? "Die KI-Prüfung hat nicht geklappt.";
if (result.Problem?.Status == 409)
{
await ReloadQuietAsync();
}
}
}
finally
{
_busy = false;
_aiRunning = false;
}
}
private int PartialPoints(LearningReviewAnswerDto item) =>
_partial.TryGetValue(item.ItemId, out var points) ? points : item.Verdict == LearningVerdict.Partial ? item.AwardedPoints : Math.Max(1, item.ItemPoints / 2);
private void SetPartialPoints(LearningReviewAnswerDto item, int value) => _partial[item.ItemId] = value;
private string? ReasonOf(LearningReviewAnswerDto item) =>
_reasons.TryGetValue(item.ItemId, out var reason) ? reason : item.Reason;
_reasons.TryGetValue(item.ItemId, out var reason) ? reason : item.Grader == LearningGrader.Ai ? null : item.Reason; // KI-Begründung ist nur für Eltern, nie als Kommentar an das Kind
private void SetReason(LearningReviewAnswerDto item, string? value) => _reasons[item.ItemId] = value;
@@ -106,6 +106,11 @@
Variant="Variant.Outlined" InputMode="@(InputMode.@decimal)" Disabled="_submitting" Class="fp-learning-input fp-learning-number"
InputId="@InputId" Clearable="false" />
break;
case LearningAnswerType.Paper:
<PhotoUpload AttemptId="AttemptId" ItemId="item.ItemId" HasPhoto="@(Value(item.ItemId) == LearningPhotoRules.AnswerMarker)"
PhotoAt="@_photoAt.GetValueOrDefault(item.ItemId)" Disabled="_submitting"
Changed="r => PhotoChangedAsync(item, r)" Conflict="LoadAsync" />
break;
default:
<MudTextField T="string" Value="@Value(item.ItemId)" ValueChanged="v => TextChangedAsync(item, v)" Label="Deine Antwort"
Variant="Variant.Outlined" Lines="3" Disabled="_submitting" Class="fp-learning-input fp-learning-text" InputId="@InputId" />
@@ -186,6 +191,7 @@
private readonly Dictionary<Guid, string?> _values = [];
private readonly Dictionary<Guid, string?> _saved = [];
private readonly Dictionary<Guid, LearningVerdict?> _verdicts = [];
private readonly Dictionary<Guid, DateTimeOffset?> _photoAt = [];
private readonly HashSet<Guid> _hints = [];
private readonly HashSet<TimeSpan> _announced = [];
private LearningAttemptDto? _attempt;
@@ -253,8 +259,10 @@
_values.Clear();
_saved.Clear();
_verdicts.Clear();
_photoAt.Clear();
foreach (var item in attempt.Items)
{
_photoAt[item.ItemId] = item.PhotoAt;
_values[item.ItemId] = item.Answer;
_saved[item.ItemId] = item.Answer;
if (attempt.Mode == LearningMode.Practice && item.Feedback is { } feedback)
@@ -317,6 +325,18 @@
return SaveAsync(item.ItemId);
}
/// <summary>Foto gespeichert (Ergebnis) oder entfernt (<c>null</c>): Serverstand übernehmen – Papieraufgaben gelten mit Foto als beantwortet.</summary>
private Task PhotoChangedAsync(LearningAttemptItemDto item, SaveAnswerResultDto? result)
{
var value = result is null ? null : LearningPhotoRules.AnswerMarker;
_values[item.ItemId] = value;
_saved[item.ItemId] = value;
_photoAt[item.ItemId] = result?.ServerNow;
_verdicts[item.ItemId] = result is not null && _attempt?.Mode == LearningMode.Practice ? result.Verdict : null;
_saveState = result is null ? SaveState.Idle : SaveState.Saved;
return Task.CompletedTask;
}
private Task TextChangedAsync(LearningAttemptItemDto item, string? value)
{
_values[item.ItemId] = value;
@@ -44,6 +44,11 @@
HelperText="Mehrere richtige Schreibweisen mit | trennen. Weicht die Antwort ab, entscheiden die Eltern."
Error="@HasError("CorrectAnswer")" ErrorText="@ErrorFor("CorrectAnswer")" />
break;
case LearningAnswerType.Paper:
<MudTextField @bind-Value="_correct" Label="Musterlösung" InputId="item-correct" Variant="Variant.Outlined" Lines="2"
HelperText="Das Kind löst auf Papier und lädt ein Foto hoch. Die Musterlösung sehen nur Eltern (und die KI-Prüfung) als Maßstab."
Error="@HasError("CorrectAnswer")" ErrorText="@ErrorFor("CorrectAnswer")" />
break;
case LearningAnswerType.SingleChoice:
<fieldset class="fp-item-options" role="radiogroup">
<legend class="mud-typography mud-typography-caption">Antwortmöglichkeiten – die richtige markieren</legend>
@@ -103,7 +108,7 @@
</MudDialog>
@code {
private static readonly LearningAnswerType[] Types = [LearningAnswerType.Number, LearningAnswerType.Text, LearningAnswerType.SingleChoice];
private static readonly LearningAnswerType[] Types = [LearningAnswerType.Number, LearningAnswerType.Text, LearningAnswerType.SingleChoice, LearningAnswerType.Paper];
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
@@ -143,7 +148,7 @@
private void Load(LearningItemDto item)
{
_question = item.Question;
_type = item.AnswerType == LearningAnswerType.Paper ? LearningAnswerType.Text : item.AnswerType;
_type = item.AnswerType;
_tolerance = item.NumberTolerance?.ToString(CultureInfo.GetCultureInfo("de-DE"));
if (item.AnswerType == LearningAnswerType.SingleChoice)
{
@@ -37,7 +37,7 @@ public static class LearningFormat
LearningAnswerType.Number => "Zahl",
LearningAnswerType.Text => "Text",
LearningAnswerType.SingleChoice => "Auswahl",
LearningAnswerType.Paper => "Papier",
LearningAnswerType.Paper => "Papier (Foto)",
_ => type.ToString(),
};
@@ -0,0 +1,172 @@
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Learning
@using Microsoft.AspNetCore.Components.Forms
@inject ILearningApi Api
@inject ISnackbar Snackbar
@* Foto der Papierlösung: Kamera oder Datei, Vorschau des gespeicherten Fotos, Ersetzen oder Entfernen (nur vor der Abgabe). *@
<div class="fp-photo-upload" data-item-id="@ItemId">
@if (HasPhoto)
{
<img class="fp-photo-preview" src="@LearningPhotoUrls.Photo(AttemptId, ItemId, PhotoAt)" alt="Dein Foto der Lösung" />
<p class="fp-photo-state" role="status" aria-live="polite">@(_busy ? "Wird hochgeladen …" : "Foto gespeichert ✓")</p>
<div class="d-flex flex-wrap gap-2">
<label class="@PickClass(false)">
<MudIcon Icon="@Icons.Material.Filled.PhotoCamera" Size="Size.Small" />
<span>Neues Foto</span>
<InputFile OnChange="OnPickedAsync" accept="image/jpeg,image/png,image/webp" capture="environment" disabled="@(_busy || Disabled)"
class="fp-photo-input fp-photo-replace" aria-label="Neues Foto aufnehmen" />
</label>
<MudButton Variant="Variant.Text" Color="Color.Error" Size="Size.Large" StartIcon="@Icons.Material.Filled.Delete" Disabled="@(_busy || Disabled)"
OnClick="RemoveAsync" Class="fp-photo-remove">Foto entfernen</MudButton>
</div>
}
else
{
<p class="fp-photo-help">Löse die Aufgabe auf Papier und lade ein Foto davon hoch. Achte auf gutes Licht und eine gut lesbare Schrift.</p>
<div class="d-flex flex-wrap gap-2">
<label class="@PickClass(true)">
<MudIcon Icon="@Icons.Material.Filled.PhotoCamera" Size="Size.Small" />
<span>Foto aufnehmen</span>
<InputFile OnChange="OnPickedAsync" accept="image/jpeg,image/png,image/webp" capture="environment" disabled="@(_busy || Disabled)"
class="fp-photo-input fp-photo-camera" aria-label="Foto aufnehmen" />
</label>
<label class="@PickClass(false)">
<MudIcon Icon="@Icons.Material.Filled.Image" Size="Size.Small" />
<span>Datei wählen</span>
<InputFile OnChange="OnPickedAsync" accept="image/jpeg,image/png,image/webp" disabled="@(_busy || Disabled)"
class="fp-photo-input fp-photo-file" aria-label="Foto aus Datei wählen" />
</label>
</div>
@if (_busy)
{
<p class="fp-photo-state" role="status" aria-live="polite">Wird hochgeladen …</p>
}
}
@if (_error is not null)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-2 fp-photo-error" role="alert">@_error</MudAlert>
}
</div>
@code {
/// <summary>Ab dieser Größe wird im Browser verkleinert (Handyfotos sind oft größer als das Limit).</summary>
private const long ShrinkAbove = 2 * 1024 * 1024;
private const int MaxSide = 2400;
private bool _busy;
private string? _error;
[Parameter, EditorRequired]
public Guid AttemptId { get; set; }
[Parameter, EditorRequired]
public Guid ItemId { get; set; }
[Parameter]
public bool HasPhoto { get; set; }
/// <summary>Zeitpunkt der letzten Speicherung – dient dem Browser als Versionsmarke, damit ein ersetztes Foto neu geladen wird.</summary>
[Parameter]
public DateTimeOffset? PhotoAt { get; set; }
[Parameter]
public bool Disabled { get; set; }
/// <summary>Foto gespeichert (Ergebnis des Servers) bzw. entfernt (<c>null</c>).</summary>
[Parameter]
public EventCallback<SaveAnswerResultDto?> Changed { get; set; }
/// <summary>Der Server meldet Abgabe oder Zeitablauf (409): der Versuch muss neu geladen werden.</summary>
[Parameter]
public EventCallback Conflict { get; set; }
private static string PickClass(bool primary) =>
$"fp-photo-pick {(primary ? "is-primary" : string.Empty)}";
private async Task OnPickedAsync(InputFileChangeEventArgs args)
{
if (_busy || args.FileCount == 0)
{
return;
}
_busy = true;
_error = null;
try
{
var file = args.File;
if (file.Size > ShrinkAbove && file.ContentType is "image/jpeg" or "image/png" or "image/webp")
{
try
{
file = await file.RequestImageFileAsync("image/jpeg", MaxSide, MaxSide);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Verkleinern ist nur eine Hilfe: ohne Browserunterstützung wird das Original geprüft.
}
}
if (file.Size > LearningPhotoRules.MaxBytes)
{
_error = $"Das Foto ist zu groß (höchstens {LearningPhotoRules.MaxBytes / 1024 / 1024} MB).";
return;
}
await using var stream = file.OpenReadStream(LearningPhotoRules.MaxBytes);
var result = await Api.UploadPhotoAsync(AttemptId, ItemId, stream, file.Name, file.ContentType);
if (result.Succeeded && result.Value is { } saved)
{
await Changed.InvokeAsync(saved);
}
else if (result.Problem?.Status == 409)
{
Snackbar.Add(result.Problem.Detail ?? "Der Versuch ist bereits abgegeben.", Severity.Warning);
await Conflict.InvokeAsync();
}
else
{
_error = result.Problem?.Message ?? "Das Foto konnte nicht hochgeladen werden. Bitte noch einmal versuchen.";
}
}
finally
{
_busy = false;
}
}
private async Task RemoveAsync()
{
if (_busy)
{
return;
}
_busy = true;
_error = null;
try
{
var result = await Api.DeletePhotoAsync(AttemptId, ItemId);
if (result.Succeeded)
{
await Changed.InvokeAsync(null);
}
else if (result.Problem?.Status == 409)
{
Snackbar.Add(result.Problem.Detail ?? "Der Versuch ist bereits abgegeben.", Severity.Warning);
await Conflict.InvokeAsync();
}
else
{
_error = result.Problem?.Message ?? "Das Foto konnte nicht entfernt werden.";
}
}
finally
{
_busy = false;
}
}
}
@@ -49,6 +49,12 @@ public interface ILearningApi
Task<ApiResult<List<UserDirectoryEntry>>> GetUsersAsync(CancellationToken ct = default);
/// <summary>
/// Lässt die KI die Fotos eines abgegebenen Versuchs prüfen (nur mit <c>AiUse</c>; die Oberfläche bietet es nur an, wenn <c>GET api/ai/status</c> die Foto-Prüfung meldet).
/// Liefert Vorschläge – bestätigt wird weiter über <see cref="ConfirmAsync"/>.
/// </summary>
Task<ApiResult<LearningAiGradeResultDto>> AiGradeAsync(Guid attemptId, LearningAiGradeRequest request, CancellationToken ct = default);
// ---- Kinder ----
Task<ApiResult<List<MyLearningSetDto>>> GetMineAsync(CancellationToken ct = default);
@@ -61,6 +67,11 @@ public interface ILearningApi
Task<ApiResult<SaveAnswerResultDto>> SaveAnswerAsync(Guid attemptId, Guid itemId, SaveAnswerRequest request, CancellationToken ct = default);
Task<ApiResult<LearningAttemptDto>> SubmitAsync(Guid attemptId, CancellationToken ct = default);
/// <summary>Foto der Papierlösung hochladen oder ersetzen. Bei 409 (abgegeben/abgelaufen) enthält <see cref="ApiResult{T}.Problem"/> die Meldung.</summary>
Task<ApiResult<SaveAnswerResultDto>> UploadPhotoAsync(Guid attemptId, Guid itemId, Stream content, string fileName, string contentType, CancellationToken ct = default);
Task<ApiResult<bool>> DeletePhotoAsync(Guid attemptId, Guid itemId, CancellationToken ct = default);
}
public sealed class LearningApi(HttpClient http) : ILearningApi
@@ -118,6 +129,9 @@ 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 Task<ApiResult<List<MyLearningSetDto>>> GetMineAsync(CancellationToken ct = default) =>
Send<List<MyLearningSetDto>>(() => http.GetAsync($"{Base}/my", ct), ct);
@@ -133,6 +147,19 @@ public sealed class LearningApi(HttpClient http) : ILearningApi
public Task<ApiResult<LearningAttemptDto>> SubmitAsync(Guid attemptId, CancellationToken ct = default) =>
Send<LearningAttemptDto>(() => http.PostAsync($"{Base}/my/attempts/{attemptId}/submit", null, ct), ct);
public async Task<ApiResult<SaveAnswerResultDto>> UploadPhotoAsync(
Guid attemptId, Guid itemId, Stream content, string fileName, string contentType, CancellationToken ct = default)
{
using var form = new MultipartFormDataContent();
var file = new StreamContent(content);
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType);
form.Add(file, "file", string.IsNullOrWhiteSpace(fileName) ? "foto" : fileName);
return await Send<SaveAnswerResultDto>(() => http.PostAsync($"{Base}/my/attempts/{attemptId}/items/{itemId}/photo", form, ct), ct);
}
public Task<ApiResult<bool>> DeletePhotoAsync(Guid attemptId, Guid itemId, CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.DeleteAsync($"{Base}/my/attempts/{attemptId}/items/{itemId}/photo", ct), ct);
/// <summary>
/// 409 ist hier meist eine fachliche Meldung (Problem-Details). Nur wenn <paramref name="currentOnConflict"/> gesetzt ist und der Body ein
/// Blatt (<c>set</c>) enthält, steht darin der aktuelle Stand nach einer Versionskollision.
+96
View File
@@ -1799,3 +1799,99 @@ a.fp-learning-title:hover {
flex: 1;
}
}
/* Lernaufgaben Phase 10c: Foto der Papierlösung */
.fp-photo-upload {
display: flex;
flex-direction: column;
gap: 10px;
}
.fp-photo-help,
.fp-photo-state {
margin: 0;
color: var(--mud-palette-text-secondary);
}
.fp-photo-preview {
display: block;
max-width: 100%;
max-height: 60vh;
border-radius: 12px;
border: 2px solid var(--mud-palette-lines-default);
object-fit: contain;
background: var(--mud-palette-surface);
}
.fp-photo-pick {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 56px;
padding: 10px 20px;
border-radius: 14px;
border: 2px solid var(--mud-palette-primary);
background: var(--mud-palette-surface);
color: var(--mud-palette-primary);
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
overflow: hidden;
}
.fp-photo-pick.is-primary {
background: var(--mud-palette-primary);
color: var(--mud-palette-primary-text);
}
.fp-photo-pick:focus-within {
outline: 3px solid var(--mud-palette-primary);
outline-offset: 2px;
}
/* Das echte Eingabefeld füllt den ganzen Knopf (gut mit dem Finger zu treffen), bleibt aber unsichtbar. */
.fp-photo-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.fp-review-paper {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: 16px;
align-items: start;
margin-bottom: 8px;
}
.fp-review-photo-img {
display: block;
max-width: 100%;
max-height: 70vh;
border-radius: 12px;
border: 1px solid var(--mud-palette-lines-default);
object-fit: contain;
}
.fp-review-confidence {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin: 4px 0;
}
@media (max-width: 599.98px) {
.fp-review-paper {
grid-template-columns: 1fr;
}
.fp-photo-pick {
flex: 1 1 100%;
}
}
@@ -86,7 +86,9 @@ internal sealed class FakeAiApi : IAiApi
public Task<ApiResult<AiUsageOverviewDto>> GetUsageAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<AiUsageOverviewDto>.Ok(Usage));
public Task<ApiResult<AiStatusDto>> GetStatusAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<AiStatusDto>.Ok(new AiStatusDto(true, true, false)));
public AiStatusDto Status { get; set; } = new(true, true, false);
public Task<ApiResult<AiStatusDto>> GetStatusAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<AiStatusDto>.Ok(Status));
}
public sealed class AiManagementTests : BunitContext
@@ -382,6 +382,52 @@ internal sealed class FakeLearningApi : ILearningApi
return ApiResult<SaveAnswerResultDto>.Ok(new SaveAnswerResultDto(itemId, request.Value is not null, verdict, attempt.DeadlineAt, ServerNow()));
}
// ---- Fotos (Phase 10c) ----
public List<(Guid Attempt, Guid Item, string FileName, string ContentType, int Length)> Uploaded { get; } = [];
public List<(Guid Attempt, Guid Item)> PhotosDeleted { get; } = [];
public List<(Guid Attempt, LearningAiGradeRequest Request)> AiGraded { get; } = [];
/// <summary>Antwort auf einen Foto-Upload; Standard: gespeichert.</summary>
public Func<ApiResult<SaveAnswerResultDto>>? UploadBehavior { get; set; }
/// <summary>Antwort auf die KI-Prüfung; Standard: kein Ergebnis (Test setzt es).</summary>
public Func<Guid, ApiResult<LearningAiGradeResultDto>>? AiGradeBehavior { get; set; }
public async Task<ApiResult<SaveAnswerResultDto>> UploadPhotoAsync(
Guid attemptId, Guid itemId, Stream content, string fileName, string contentType, CancellationToken ct = default)
{
using var buffer = new MemoryStream();
await content.CopyToAsync(buffer, ct);
Uploaded.Add((attemptId, itemId, fileName, contentType, (int)buffer.Length));
if (UploadBehavior is { } behavior)
{
return behavior();
}
var attempt = Attempts[attemptId];
Attempts[attemptId] = attempt with
{
Items = attempt.Items.Select(i => i.ItemId == itemId ? i with { Answer = LearningPhotoRules.AnswerMarker, HasPhoto = true, PhotoAt = ServerNow() } : i).ToList(),
};
return ApiResult<SaveAnswerResultDto>.Ok(new SaveAnswerResultDto(itemId, true, null, attempt.DeadlineAt, ServerNow()));
}
public Task<ApiResult<bool>> DeletePhotoAsync(Guid attemptId, Guid itemId, CancellationToken ct = default)
{
PhotosDeleted.Add((attemptId, itemId));
var attempt = Attempts[attemptId];
Attempts[attemptId] = attempt with { Items = attempt.Items.Select(i => i.ItemId == itemId ? i with { Answer = null, HasPhoto = false, PhotoAt = null } : i).ToList() };
return Task.FromResult(ApiResult<bool>.Ok(true));
}
public Task<ApiResult<LearningAiGradeResultDto>> AiGradeAsync(Guid attemptId, LearningAiGradeRequest request, CancellationToken ct = default)
{
AiGraded.Add((attemptId, request));
return Task.FromResult(AiGradeBehavior?.Invoke(attemptId) ?? ApiResult<LearningAiGradeResultDto>.Fail(new ApiProblem { Status = 409, Detail = "nicht eingerichtet" }));
}
public Task<ApiResult<LearningAttemptDto>> SubmitAsync(Guid attemptId, CancellationToken ct = default)
{
Submitted.Add(attemptId);
@@ -2,6 +2,7 @@ using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Shared.Auth;
using FamilyPlanner.Web.Client.Auth;
using FamilyPlanner.Web.Client.Services;
using FamilyPlanner.Web.Tests.Admin;
using FamilyPlanner.Web.Tests.Shopping;
using FamilyPlanner.Web.Tests.Tasks;
using Microsoft.Extensions.DependencyInjection;
@@ -16,6 +17,9 @@ public abstract class LearningTestBase : BunitContext
internal FakeLiveConnection Live { get; } = new();
/// <summary>KI-Status für die Eltern-Prüfansicht; Standard: Foto-Prüfung nicht eingerichtet (kein KI-Knopf).</summary>
internal FakeAiApi Ai { get; } = new();
internal ManualTimeProvider Time { get; } = new(FakeLearningApi.Now);
protected LearningTestBase()
@@ -24,6 +28,7 @@ public abstract class LearningTestBase : BunitContext
Services.AddMudServices();
Services.AddSingleton<ILearningApi>(Api);
Services.AddSingleton<ILiveConnection>(Live);
Services.AddSingleton<IAiApi>(Ai);
Services.AddSingleton<IAuthService, NoopAuthService>();
Services.AddSingleton<ApiAuthenticationStateProvider>();
Services.AddSingleton<TimeProvider>(Time);
@@ -0,0 +1,262 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Ai;
using FamilyPlanner.Shared.Learning;
using FamilyPlanner.Web.Client.Components.Learning;
using FamilyPlanner.Web.Client.Services;
using Microsoft.AspNetCore.Components.Forms;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>Papierlösung per Foto (Phase 10c): Upload-Feld im Runner, Anzeige neben Aufgabe und Musterlösung in der Prüfung, KI-Knopf nur bei eingerichteter Foto-Prüfung.</summary>
public sealed class PaperPhotoTests : LearningTestBase
{
private static LearningAttemptDto PaperAttempt(bool withPhoto = false)
{
var item = FakeLearningApi.AttemptItem(0, "Löse auf Papier: 14 + 29", LearningAnswerType.Paper, 3, answer: withPhoto ? LearningPhotoRules.AnswerMarker : null) with
{
HasPhoto = withPhoto,
PhotoAt = withPhoto ? FakeLearningApi.Now : null,
};
return FakeLearningApi.NewAttempt("Heft", LearningMode.Practice, [item]);
}
private IRenderedComponent<LearningHost> OpenRunner(LearningAttemptDto attempt)
{
Api.Attempts[attempt.Id] = attempt;
SignInAs(Roles.Child);
return Render<LearningHost>(p => p.AddChildContent<AttemptRunner>(c => c.Add(x => x.AttemptId, attempt.Id)));
}
private static InputFileContent Png(int length = 8) => InputFileContent.CreateFromBinary(new byte[length], "heft.png", null, "image/png");
// ---------- Kind ----------
[Fact]
public void Paper_questions_get_camera_and_file_pickers_instead_of_a_text_field()
{
var cut = OpenRunner(PaperAttempt());
Assert.Empty(cut.FindAll(".fp-learning-input"));
Assert.Single(cut.FindAll(".fp-photo-camera"));
Assert.Equal("environment", cut.Find(".fp-photo-camera").GetAttribute("capture")); // öffnet auf dem Handy direkt die Kamera
Assert.Single(cut.FindAll(".fp-photo-file"));
Assert.Equal("image/jpeg,image/png,image/webp", cut.Find(".fp-photo-file").GetAttribute("accept"));
Assert.Empty(cut.FindAll(".fp-photo-preview"));
Assert.Equal("0 von 1 beantwortet", cut.Find(".fp-learning-progress-text").TextContent);
}
[Fact]
public void Uploading_stores_the_photo_through_the_photo_endpoint_shows_a_preview_and_counts_as_answered()
{
var attempt = PaperAttempt();
var cut = OpenRunner(attempt);
cut.FindComponent<InputFile>().UploadFiles(Png(12));
cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".fp-photo-preview")));
Assert.Equal([(attempt.Id, attempt.Items[0].ItemId, "heft.png", "image/png", 12)], Api.Uploaded);
Assert.Empty(Api.Saved); // nie über den Text-Endpunkt
var source = cut.Find(".fp-photo-preview").GetAttribute("src")!;
Assert.StartsWith($"/api/learning/attempts/{attempt.Id}/items/{attempt.Items[0].ItemId}/photo?v=", source);
Assert.Contains("Foto gespeichert", cut.Find(".fp-photo-state").TextContent);
Assert.Equal("1 von 1 beantwortet", cut.Find(".fp-learning-progress-text").TextContent);
Assert.Empty(cut.FindAll(".fp-photo-camera")); // statt der Auswahl: Ersetzen und Entfernen
Assert.Single(cut.FindAll(".fp-photo-replace"));
}
[Fact]
public void A_stored_photo_can_be_replaced_or_removed_before_submitting()
{
var attempt = PaperAttempt(withPhoto: true);
var cut = OpenRunner(attempt);
Assert.Single(cut.FindAll(".fp-photo-preview"));
Assert.Equal("1 von 1 beantwortet", cut.Find(".fp-learning-progress-text").TextContent);
cut.FindComponent<InputFile>().UploadFiles(Png(5));
cut.WaitForAssertion(() => Assert.Single(Api.Uploaded));
cut.Find(".fp-photo-remove").Click();
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-photo-preview")));
Assert.Equal([(attempt.Id, attempt.Items[0].ItemId)], Api.PhotosDeleted);
Assert.Equal("0 von 1 beantwortet", cut.Find(".fp-learning-progress-text").TextContent);
Assert.Single(cut.FindAll(".fp-photo-camera"));
}
[Fact]
public void Too_large_photos_are_rejected_in_the_browser_without_an_upload()
{
var cut = OpenRunner(PaperAttempt());
cut.FindComponent<InputFile>().UploadFiles(Png(LearningPhotoRules.MaxBytes + 1));
cut.WaitForAssertion(() => Assert.Contains("zu groß", cut.Find(".fp-photo-error").TextContent));
Assert.Empty(Api.Uploaded);
Assert.Empty(cut.FindAll(".fp-photo-preview"));
}
[Fact]
public void Server_errors_are_shown_without_losing_the_picker()
{
var cut = OpenRunner(PaperAttempt());
Api.UploadBehavior = () => ApiResult<SaveAnswerResultDto>.Fail(new ApiProblem { Status = 400, Detail = "Bitte ein Foto im Format PNG, JPEG oder WebP senden." });
cut.FindComponent<InputFile>().UploadFiles(Png());
cut.WaitForAssertion(() => Assert.Contains("Format PNG, JPEG oder WebP", cut.Find(".fp-photo-error").TextContent));
Assert.Single(cut.FindAll(".fp-photo-camera"));
}
[Fact]
public void A_conflict_after_the_deadline_reloads_the_attempt()
{
var cut = OpenRunner(PaperAttempt());
Api.UploadBehavior = () => ApiResult<SaveAnswerResultDto>.Fail(new ApiProblem { Status = 409, Detail = "Die Zeit ist abgelaufen. Der Versuch wurde abgegeben." });
var calls = Api.GetAttemptCalls;
cut.FindComponent<InputFile>().UploadFiles(Png());
cut.WaitForAssertion(() => Assert.True(Api.GetAttemptCalls > calls));
}
// ---------- Eltern: Prüfung ----------
private static LearningReviewAnswerDto PaperAnswer(
LearningPhotoState state, LearningVerdict verdict = LearningVerdict.Open, LearningGrader grader = LearningGrader.Auto, int? confidence = null, string? reason = null, int points = 3) =>
new(Guid.NewGuid(), 0, "Löse auf Papier: 14 + 29", LearningAnswerType.Paper, [], "43", null, points,
state == LearningPhotoState.Available ? LearningPhotoRules.AnswerMarker : null, verdict, grader, verdict == LearningVerdict.Correct ? points : 0, reason,
state, state == LearningPhotoState.Available ? FakeLearningApi.Now : null, confidence);
private LearningAttemptReviewDto Review(LearningAttemptStatus status = LearningAttemptStatus.Submitted, params LearningReviewAnswerDto[] answers)
{
var score = LearningRules.Score(answers.Select(a => new ScoredAnswer(a.ItemPoints, a.Verdict, a.AwardedPoints)));
var review = new LearningAttemptReviewDto(
Guid.NewGuid(), Guid.NewGuid(), "Heft", LearningSubjects.Math, LearningMode.Practice, 10, FakeLearningApi.Mia, "Mia", 1, status,
FakeLearningApi.Now.AddMinutes(-30), FakeLearningApi.Now.AddMinutes(-5), false, answers, score.Earned, answers.Sum(a => a.ItemPoints), score.OpenCount, null, 0, null, [1]);
Api.Reviews[review.AttemptId] = review;
return review;
}
private IRenderedComponent<LearningHost> OpenReview(LearningAttemptReviewDto review)
{
SignInAs(Roles.Family, "Papa", FakeLearningApi.Me);
return Render<LearningHost>(p => p.AddChildContent<AttemptReview>(c => c.Add(x => x.AttemptId, review.AttemptId)));
}
[Fact]
public void The_review_shows_the_photo_next_to_the_solution_with_a_versioned_address_and_alt_text()
{
var review = Review(answers: PaperAnswer(LearningPhotoState.Available));
var cut = OpenReview(review);
var image = cut.Find(".fp-review-paper .fp-review-photo-img");
Assert.StartsWith($"/api/learning/attempts/{review.AttemptId}/items/{review.Answers[0].ItemId}/photo?v=", image.GetAttribute("src"));
Assert.Contains("Mia", image.GetAttribute("alt"));
Assert.Contains("43", cut.Find(".fp-review-paper-solution").TextContent);
Assert.Empty(cut.FindAll(".fp-review-given")); // kein Antworttext bei Papieraufgaben
Assert.NotEmpty(cut.FindAll(".fp-review-actions")); // manuell bewerten
}
[Fact]
public void Deleted_and_missing_photos_are_explained()
{
var review = Review(answers: [PaperAnswer(LearningPhotoState.Purged), PaperAnswer(LearningPhotoState.None)]);
var cut = OpenReview(review);
Assert.Empty(cut.FindAll(".fp-review-photo-img"));
Assert.Contains("nach Ablauf der Aufbewahrungsfrist gelöscht", cut.Find(".fp-review-photo-purged").TextContent);
Assert.Contains("kein Foto abgegeben", cut.Find(".fp-review-photo-missing").TextContent);
}
[Fact]
public void The_ai_button_is_hidden_when_the_status_does_not_offer_photo_grading()
{
Ai.Status = new AiStatusDto(true, true, false);
Assert.Empty(OpenReview(Review(answers: PaperAnswer(LearningPhotoState.Available))).FindAll(".fp-review-ai-button"));
}
[Fact]
public void The_ai_button_appears_when_the_status_offers_photo_grading()
{
Ai.Status = new AiStatusDto(true, true, true);
Assert.Single(OpenReview(Review(answers: PaperAnswer(LearningPhotoState.Available))).FindAll(".fp-review-ai-button"));
}
[Fact]
public void The_ai_button_is_hidden_when_every_photo_is_gone()
{
Ai.Status = new AiStatusDto(true, true, true);
Assert.Empty(OpenReview(Review(answers: PaperAnswer(LearningPhotoState.Purged))).FindAll(".fp-review-ai-button"));
}
[Fact]
public void The_ai_button_is_hidden_when_parents_already_graded_the_photo()
{
Ai.Status = new AiStatusDto(true, true, true);
Assert.Empty(OpenReview(Review(answers: PaperAnswer(LearningPhotoState.Available, LearningVerdict.Correct, LearningGrader.Parent))).FindAll(".fp-review-ai-button"));
}
[Fact]
public void The_ai_button_is_hidden_after_confirmation()
{
Ai.Status = new AiStatusDto(true, true, true);
Assert.Empty(OpenReview(Review(LearningAttemptStatus.Reviewed, PaperAnswer(LearningPhotoState.Available, LearningVerdict.Correct))).FindAll(".fp-review-ai-button"));
}
[Fact]
public void Running_the_ai_asks_for_the_privacy_notice_first_and_shows_suggestion_with_confidence()
{
Ai.Status = new AiStatusDto(true, true, true);
var review = Review(answers: PaperAnswer(LearningPhotoState.Available));
var graded = review with
{
Answers = [review.Answers[0] with { Verdict = LearningVerdict.Correct, Grader = LearningGrader.Ai, AwardedPoints = 3, AiConfidence = 92, Reason = "KI: Die Rechnung stimmt." }],
EarnedPoints = 3,
OpenCount = 0,
};
Api.AiGradeBehavior = _ => ApiResult<LearningAiGradeResultDto>.Ok(new LearningAiGradeResultDto(1, 0, 0, null, graded));
var cut = OpenReview(review);
var click = cut.InvokeAsync(() => cut.Find(".fp-review-ai-button").Click());
cut.WaitForAssertion(() => Assert.Contains("Fotos von der KI prüfen lassen?", cut.Markup));
Assert.Contains("verlassen die Fotos der Kinderhefte das Heimnetz", cut.Markup);
Assert.Empty(Api.AiGraded);
cut.FindAll("button").First(b => Text(b) == "Abbrechen").Click();
cut.WaitForAssertion(() => Assert.DoesNotContain("Fotos von der KI prüfen lassen?", cut.Markup));
Assert.Empty(Api.AiGraded);
var again = cut.InvokeAsync(() => cut.Find(".fp-review-ai-button").Click());
cut.WaitForAssertion(() => Assert.Contains("Fotos von der KI prüfen lassen?", cut.Markup));
cut.FindAll("button").First(b => Text(b) == "Ja, prüfen lassen").Click();
cut.WaitForAssertion(() => Assert.Single(Api.AiGraded));
Assert.True(Api.AiGraded[0].Request.AcknowledgePrivacy);
cut.WaitForAssertion(() => Assert.Contains("Sicherheit der KI: 92 %", cut.Find(".fp-review-confidence").TextContent));
Assert.Contains("von der KI vorgeschlagen", cut.Find(".fp-review-grader").TextContent);
Assert.Contains("Nur ein Vorschlag", cut.Find(".fp-review-confidence").TextContent);
Assert.NotEmpty(cut.FindAll(".fp-review-confirm")); // bestätigt wird weiterhin von Hand
Assert.True(click.IsCompleted || !click.IsFaulted);
Assert.True(again.IsCompleted || !again.IsFaulted);
}
[Fact]
public void Low_confidence_stays_open_and_the_ai_reason_is_not_prefilled_as_a_comment_to_the_child()
{
var review = Review(answers: PaperAnswer(LearningPhotoState.Available, LearningVerdict.Open, LearningGrader.Ai, 40, "KI: unleserlich"));
var cut = OpenReview(review);
Assert.Contains("Die KI ist unsicher", cut.Find(".fp-review-confidence").TextContent);
Assert.Contains("KI: unleserlich", cut.Find(".fp-review-grader").TextContent);
Assert.Equal(string.Empty, cut.Find(".fp-review-reason input").GetAttribute("value") ?? string.Empty);
cut.Find(".fp-review-correct-button").Click();
cut.WaitForAssertion(() => Assert.Single(Api.Reviewed));
Assert.Null(Api.Reviewed[0].Request.Reason); // die KI-Begründung geht nicht als Elternkommentar an das Kind
}
}