feat: add AI draft assistant to the learning UI

Adds the Mit KI erstellen dialog to the parent overview (shown from
GET /api/ai/status), a cloud privacy hint, the review report and a
KI-generiert, bitte pruefen notice in the set editor. bUnit coverage for
visibility, dialog states and escaped output.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 14:51:16 +02:00
1 parent b68c708113
commit 2cd976ee3e
9 files changed
+680 -1

No files matched your search

@@ -0,0 +1,245 @@
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Admin
@using FamilyPlanner.Shared.Learning
@inject ILearningApi Api
@* Assistent „Mit KI erstellen“ (Phase 10b): Eltern beschreiben das Blatt, die KI liefert einen ENTWURF, der im Editor geprüft wird. Alle KI-Texte werden nur als Text angezeigt. *@
<MudDialog>
<DialogContent>
@if (_result is null)
{
<EditForm Model="this" OnSubmit="CreateAsync" FormName="learning-ai-form" id="learning-ai-form">
<MudStack Spacing="2">
@if (IsCloud)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="fp-ai-privacy">
Datenschutz: Die KI läuft bei einem Cloud-Anbieter außerhalb des Heimnetzes. Fach, Klasse und dein Zusatzwunsch werden dorthin gesendet –
schreibe keine Namen oder privaten Angaben hinein.
</MudAlert>
}
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" role="alert" Class="fp-dialog-error">@_error</MudAlert>
}
<div class="d-flex flex-wrap gap-2">
<MudSelect T="string" @bind-Value="_subject" Label="Fach" Variant="Variant.Outlined" Class="fp-task-field fp-ai-subject" Disabled="_busy"
Error="@HasError("Subject")" ErrorText="@ErrorFor("Subject")">
@foreach (var subject in LearningSubjects.All)
{
<MudSelectItem T="string" Value="@subject">@subject</MudSelectItem>
}
</MudSelect>
<MudSelect T="int" @bind-Value="_gradeLevel" Label="Klassenstufe" Variant="Variant.Outlined" Class="fp-task-field fp-ai-grade" Disabled="_busy"
ToStringFunc="@(g => LearningFormat.GradeLevel(g))" Error="@HasError("GradeLevel")" ErrorText="@ErrorFor("GradeLevel")">
@for (var level = LearningSet.MinGradeLevel; level <= LearningSet.MaxGradeLevel; level++)
{
<MudSelectItem T="int" Value="@level">@LearningFormat.GradeLevel(level)</MudSelectItem>
}
</MudSelect>
</div>
<div class="d-flex flex-wrap gap-2">
<MudSelect T="Guid" @bind-Value="_target" Label="Für Kind" Variant="Variant.Outlined" Class="fp-task-field fp-ai-target" Disabled="_busy"
ToStringFunc="@(id => Children.FirstOrDefault(c => c.Id == id)?.DisplayName ?? string.Empty)"
Error="@HasError("TargetUserId")" ErrorText="@ErrorFor("TargetUserId")">
@foreach (var child in Children)
{
<MudSelectItem T="Guid" Value="@child.Id">@child.DisplayName</MudSelectItem>
}
</MudSelect>
<MudNumericField T="int" @bind-Value="_count" Label="Anzahl Aufgaben" Min="LearningAiDraft.MinCount" Max="LearningAiDraft.MaxCount" Variant="Variant.Outlined"
Class="fp-task-field fp-ai-count" Disabled="_busy" Error="@HasError("Count")" ErrorText="@ErrorFor("Count")"
HelperText="@($"{LearningAiDraft.MinCount} bis {LearningAiDraft.MaxCount}")" />
</div>
<MudSelect T="LearningDifficultyCurve" @bind-Value="_difficulty" Label="Schwierigkeit" Variant="Variant.Outlined" Class="fp-ai-difficulty" Disabled="_busy"
ToStringFunc="@(d => Curve(d))" Error="@HasError("Difficulty")" ErrorText="@ErrorFor("Difficulty")">
@foreach (var curve in Enum.GetValues<LearningDifficultyCurve>())
{
<MudSelectItem T="LearningDifficultyCurve" Value="@curve">@Curve(curve)</MudSelectItem>
}
</MudSelect>
<MudSelect T="LearningMode" @bind-Value="_mode" Label="Modus" Variant="Variant.Outlined" Class="fp-ai-mode" Disabled="_busy"
ToStringFunc="@(m => m == LearningMode.Exam ? "Prüfung (einmalig, ohne Hilfen)" : "Übung (Sofort-Rückmeldung, Hinweise)")">
<MudSelectItem T="LearningMode" Value="@LearningMode.Practice">Übung (Sofort-Rückmeldung, Hinweise)</MudSelectItem>
<MudSelectItem T="LearningMode" Value="@LearningMode.Exam">Prüfung (einmalig, ohne Hilfen)</MudSelectItem>
</MudSelect>
<MudTextField @bind-Value="_extra" Label="Zusatzwunsch (optional)" InputId="ai-extra" Variant="Variant.Outlined" Lines="3" Class="fp-ai-extra" Disabled="_busy"
MaxLength="LearningAiDraft.ExtraMaxLength" Counter="LearningAiDraft.ExtraMaxLength" Immediate="true"
Placeholder="z. B. Textaufgaben zum Einkaufen, nur Malnehmen mit 3 und 4"
HelperText="Thema oder Stil – die KI behandelt das nur als Wunsch."
Error="@HasError("Extra")" ErrorText="@ErrorFor("Extra")" />
<MudText Typo="Typo.body2" Class="fp-ai-hint">
Die KI erstellt einen <b>Entwurf</b>. Du prüfst und änderst ihn im Editor; das Kind sieht das Blatt erst, wenn du es zuweist.
</MudText>
@if (_busy)
{
<div class="d-flex align-center gap-2 fp-ai-busy" role="status" aria-live="polite">
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
<span>Die KI erstellt das Blatt … das kann bis zu einer Minute dauern.</span>
</div>
}
</MudStack>
</EditForm>
}
else
{
var draft = _result;
<MudStack Spacing="2" Class="fp-ai-result">
<MudAlert Severity="Severity.Success" Class="fp-ai-done">
Entwurf „@draft.Draft.Set.Title“ mit @(draft.Draft.Items.Count == 1 ? "1 Aufgabe" : $"{draft.Draft.Items.Count} Aufgaben") erstellt.
</MudAlert>
<MudAlert Severity="Severity.Info" Dense="true" Class="fp-ai-review-hint">
KI-generiert, bitte prüfen: Lies jede Aufgabe und Lösung durch, bevor du das Blatt zuweist.
</MudAlert>
<ul class="fp-ai-stats">
@if (draft.Verified > 0)
{
<li class="fp-ai-verified">@(draft.Verified == 1 ? "1 Rechenaufgabe" : $"{draft.Verified} Rechenaufgaben") nachgerechnet – die Lösung stimmt.</li>
}
@if (draft.NotVerified > 0)
{
<li class="fp-ai-notverified">@(draft.NotVerified == 1 ? "1 Rechenaufgabe" : $"{draft.NotVerified} Rechenaufgaben") konnte nicht nachgerechnet werden – bitte selbst prüfen.</li>
}
@if (draft.Discarded > 0)
{
<li class="fp-ai-discarded">@(draft.Discarded == 1 ? "1 Aufgabe" : $"{draft.Discarded} Aufgaben") verworfen (ungültig oder falsch gerechnet).</li>
}
</ul>
@if (draft.Notes.Count > 0)
{
<div>
<MudText Typo="Typo.subtitle2">Hinweise zur Prüfung</MudText>
<ul class="fp-ai-notes">
@foreach (var note in draft.Notes)
{
<li>@note</li>
}
</ul>
</div>
}
</MudStack>
}
</DialogContent>
<DialogActions>
@if (_result is null)
{
<MudButton OnClick="Cancel" Disabled="_busy" Class="fp-ai-cancel">Abbrechen</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="CreateAsync" Disabled="@(_busy || Children.Count == 0)" Class="fp-ai-create"
StartIcon="@Icons.Material.Filled.AutoAwesome">Entwurf erstellen</MudButton>
}
else
{
<MudButton OnClick="Cancel" Class="fp-ai-later">Später ansehen</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="OpenEditor" Class="fp-ai-open">Im Editor öffnen</MudButton>
}
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
/// <summary>Kinder, für die das Blatt erstellt werden kann.</summary>
[Parameter, EditorRequired]
public IReadOnlyList<UserDirectoryEntry> Children { get; set; } = [];
/// <summary>Der Anbieter liegt außerhalb des Heimnetzes: Datenschutzhinweis anzeigen.</summary>
[Parameter]
public bool IsCloud { get; set; }
private string _subject = LearningSubjects.Math;
private int _gradeLevel = 3;
private Guid _target;
private int _count = 10;
private LearningDifficultyCurve _difficulty = LearningDifficultyCurve.EasyToHard;
private LearningMode _mode = LearningMode.Practice;
private string? _extra;
private bool _busy;
private string? _error;
private Dictionary<string, string> _errors = [];
private LearningAiDraftDto? _result;
protected override void OnInitialized()
{
if (Children.Count > 0)
{
_target = Children[0].Id;
}
}
internal static string Curve(LearningDifficultyCurve curve) => curve switch
{
LearningDifficultyCurve.EasyToHard => "Von leicht bis schwer",
LearningDifficultyCurve.Easy => "Durchgehend leicht",
LearningDifficultyCurve.Medium => "Durchgehend mittel",
LearningDifficultyCurve.Hard => "Durchgehend schwer",
_ => "Gemischt",
};
private bool HasError(string field) => _errors.ContainsKey(field);
private string? ErrorFor(string field) => _errors.GetValueOrDefault(field);
private async Task CreateAsync()
{
if (_busy)
{
return;
}
_error = null;
_errors = [];
var request = new CreateAiDraftRequest(_subject, _gradeLevel, _target, _count, _difficulty, string.IsNullOrWhiteSpace(_extra) ? null : _extra.Trim(), _mode);
// Sofortiges Feedback mit denselben Regeln wie die Api (verbindlich ist die serverseitige Prüfung).
foreach (var failure in new CreateAiDraftRequestValidator().Validate(request).Errors)
{
_errors.TryAdd(failure.PropertyName, failure.ErrorMessage);
}
if (_errors.Count > 0)
{
return;
}
_busy = true;
try
{
var response = await Api.CreateAiDraftAsync(request);
if (response.Succeeded && response.Value is { } draft)
{
_result = draft;
}
else if (response.Problem?.Errors is { Count: > 0 } errors)
{
foreach (var (field, messages) in errors)
{
_errors[field] = string.Join(" ", messages);
}
_error = "Bitte die Eingaben prüfen.";
}
else
{
_error = response.Problem?.Message ?? "Der Entwurf konnte nicht erstellt werden.";
}
}
finally
{
_busy = false;
}
}
private void OpenEditor() => Dialog?.Close(DialogResult.Ok(_result!.Draft.Set.Id));
private void Cancel() => Dialog?.Close(_result is null ? DialogResult.Cancel() : DialogResult.Ok(Guid.Empty));
}
@@ -0,0 +1,33 @@
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Learning
@* Herkunftshinweis für KI-Blätter (nur Elternansicht). Prompt, Anbieter und Modell werden ausschließlich als Text ausgegeben. *@
@if (Set.Source == LearningSource.Ai)
{
<MudAlert Severity="@(Set.Status == LearningSetStatus.Draft ? Severity.Warning : Severity.Info)" Dense="true" Class="mb-2 fp-ai-notice">
@if (Set.Status == LearningSetStatus.Draft)
{
<strong>KI-generiert, bitte prüfen.</strong>
<span> Lies alle Aufgaben und Lösungen durch und ändere, was nicht stimmt – das Kind sieht das Blatt erst nach dem Zuweisen.</span>
}
else
{
<strong>KI-generiert.</strong>
}
@if (!string.IsNullOrWhiteSpace(Set.AiProvider))
{
<span class="fp-ai-origin"> Erstellt mit @Set.AiProvider@(string.IsNullOrWhiteSpace(Set.AiModel) ? string.Empty : $" ({Set.AiModel})").</span>
}
@if (!string.IsNullOrWhiteSpace(Set.AiPrompt))
{
<div class="fp-ai-prompt">Auftrag: @Set.AiPrompt</div>
}
</MudAlert>
}
@code {
[Parameter, EditorRequired]
public LearningSetDto Set { get; set; } = default!;
}
@@ -1,9 +1,12 @@
@using FamilyPlanner.Domain.Common
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Admin
@using FamilyPlanner.Shared.Ai
@using FamilyPlanner.Shared.Learning
@implements IDisposable
@inject ILearningApi Api
@inject IAiApi AiApi
@inject NavigationManager Navigation
@inject ILiveConnection Live
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@@ -14,6 +17,11 @@
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudText Typo="Typo.h4" HtmlTag="h1">Lernen</MudText>
<MudSpacer />
@if (_ai is { CanGenerateLearning: true })
{
<MudButton OnClick="OpenAiDraftAsync" Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.AutoAwesome" Class="fp-learning-ai">Mit KI erstellen</MudButton>
}
<MudButton Href="lernen/neu" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Class="fp-learning-new">Neues Blatt</MudButton>
</div>
@@ -105,6 +113,10 @@
<a href="@($"lernen/blatt/{set.Id}")" class="fp-learning-title">@set.Title</a>
<span class="fp-learning-meta">
<MudChip T="string" Size="Size.Small" Color="@LearningFormat.StatusColor(set.Status)" Class="fp-learning-status">@LearningFormat.Status(set.Status)</MudChip>
@if (set.Source == LearningSource.Ai)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Icon="@Icons.Material.Filled.AutoAwesome" Class="fp-learning-ai-chip">KI</MudChip>
}
<span>@NameOf(set.TargetUserId)</span>
<span>@set.Subject · @LearningFormat.GradeLevel(set.GradeLevel)</span>
<span>@LearningFormat.Mode(set.Mode)@(set.TimeLimitMinutes is { } minutes ? $" · {minutes} Min." : string.Empty)</span>
@@ -150,6 +162,7 @@
private Guid? _childFilter;
private bool _loading = true;
private string? _error;
private AiStatusDto? _ai;
private DateOnly Today => FamilyTime.Today(TimeProvider);
@@ -159,6 +172,46 @@
Live.Connected += LoadAsync;
await Live.EnsureStartedAsync();
await LoadAsync();
await LoadAiStatusAsync();
}
/// <summary>Die KI-Funktion ist optional: Fehler (z. B. 403, keine Verbindung) blenden nur den Knopf aus.</summary>
private async Task LoadAiStatusAsync()
{
try
{
var status = await AiApi.GetStatusAsync();
_ai = status.Succeeded ? status.Value : null;
}
catch (HttpRequestException)
{
_ai = null;
}
await InvokeAsync(StateHasChanged);
}
private async Task OpenAiDraftAsync()
{
var parameters = new DialogParameters<AiDraftDialog>
{
{ x => x.Children, _children },
{ x => x.IsCloud, _ai?.LearningGenerationIsCloud ?? false },
};
var dialog = await DialogService.ShowAsync<AiDraftDialog>(
"Mit KI erstellen", parameters, new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseOnEscapeKey = true });
var result = await dialog.Result;
if (result is { Canceled: false, Data: Guid id })
{
if (id == Guid.Empty)
{
await LoadAsync(); // Entwurf liegt in der Liste
}
else
{
Navigation.NavigateTo($"lernen/blatt/{id}");
}
}
}
private Task OnLearningChanged(LearningChangedDto _) => LoadAsync();
@@ -47,6 +47,11 @@
</MudAlert>
}
@if (_detail is { } aiSet)
{
<AiDraftNotice Set="aiSet.Set" />
}
<EditForm Model="this" OnSubmit="SaveAsync" FormName="learning-set-form" id="learning-set-form">
<MudPaper Outlined="true" Class="pa-3 mb-3">
<MudStack Spacing="2">
@@ -19,6 +19,12 @@ public interface ILearningApi
Task<ApiResult<LearningSetDetailDto>> CreateSetAsync(CreateLearningSetRequest request, CancellationToken ct = default);
/// <summary>
/// Lässt die KI ein Blatt als <b>Entwurf</b> erzeugen (Phase 10b). Die Antwort enthält den gespeicherten Entwurf und den Prüfbericht; bei Fehlern
/// (keine KI eingerichtet, Tageslimit, unbrauchbare Antwort) steht die Meldung in <see cref="ApiResult{T}.Problem"/>.
/// </summary>
Task<ApiResult<LearningAiDraftDto>> CreateAiDraftAsync(CreateAiDraftRequest request, CancellationToken ct = default);
/// <summary>Bei 409 wegen veralteter Version enthält <see cref="ApiResult{T}.Value"/> den aktuellen Stand.</summary>
Task<ApiResult<LearningSetDetailDto>> UpdateSetAsync(Guid id, UpdateLearningSetRequest request, CancellationToken ct = default);
@@ -76,6 +82,22 @@ public sealed class LearningApi(HttpClient http) : ILearningApi
public Task<ApiResult<LearningSetDetailDto>> CreateSetAsync(CreateLearningSetRequest request, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.PostAsJsonAsync($"{Base}/sets", request, ct), ct);
public async Task<ApiResult<LearningAiDraftDto>> CreateAiDraftAsync(CreateAiDraftRequest request, CancellationToken ct = default)
{
try
{
return await Send<LearningAiDraftDto>(() => http.PostAsJsonAsync($"{Base}/sets/ai-draft", request, ct), ct);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// HttpClient-Zeitlimit (langsame KI): Der Server arbeitet eventuell weiter, ein fertiger Entwurf erscheint dann in der Liste.
return ApiResult<LearningAiDraftDto>.Fail(new ApiProblem
{
Detail = "Die KI braucht zu lange. Ein fertiger Entwurf erscheint gleich in der Liste; sonst versuche es mit weniger Aufgaben noch einmal.",
});
}
}
public Task<ApiResult<LearningSetDetailDto>> UpdateSetAsync(Guid id, UpdateLearningSetRequest request, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.PutAsJsonAsync($"{Base}/sets/{id}", request, ct), ct, currentOnConflict: true);
@@ -23,6 +23,11 @@ internal sealed class FakeAiApi : IAiApi
public ApiProblem? SaveProblem { get; set; }
/// <summary>Antwort von <c>GET /api/ai/status</c>; <c>null</c> = Aufruf scheitert (z. B. 403).</summary>
public AiStatusDto? Status { get; set; } = new(true, true, false);
public int StatusCalls { get; private set; }
private int _version;
public AiProviderDto Seed(string name, AiProviderType type = AiProviderType.Ollama, string url = "http://localhost:11434", AiPurpose purposes = AiPurpose.None,
@@ -86,7 +91,11 @@ 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 Task<ApiResult<AiStatusDto>> GetStatusAsync(CancellationToken ct = default)
{
StatusCalls++;
return Task.FromResult(Status is { } status ? ApiResult<AiStatusDto>.Ok(status) : ApiResult<AiStatusDto>.Fail(new ApiProblem { Status = 403 }));
}
}
public sealed class AiManagementTests : BunitContext
@@ -0,0 +1,276 @@
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;
using Microsoft.Extensions.DependencyInjection;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>KI-Entwurf (Phase 10b) in der Oberfläche: Knopf nur bei verfügbarer KI, Assistent-Dialog, Prüfbericht, Herkunftshinweis im Editor.</summary>
public sealed class AiDraftTests : LearningTestBase
{
private IRenderedComponent<LearningHost> OpenOverview()
{
SignInAs(Roles.Family, "Papa", FakeLearningApi.Me);
return Render<LearningHost>(p => p.AddChildContent<ParentOverview>());
}
private IRenderedComponent<LearningHost> OpenDialog(bool cloud = false)
{
AiApi.Status = new AiStatusDto(true, true, false, cloud);
var cut = OpenOverview();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-learning-ai")));
cut.Find(".fp-learning-ai").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-ai-create")));
return cut;
}
private string CurrentUri => Services.GetRequiredService<NavigationManager>().Uri;
// ---------- Sichtbarkeit ----------
[Fact]
public void Button_is_hidden_when_no_ai_is_set_up()
{
AiApi.Status = new AiStatusDto(false, false, false);
var cut = OpenOverview();
cut.WaitForAssertion(() => Assert.True(AiApi.StatusCalls > 0));
Assert.Empty(cut.FindAll(".fp-learning-ai"));
Assert.NotEmpty(cut.FindAll(".fp-learning-new")); // der Rest der Seite ist unberührt
}
[Fact]
public void Button_is_hidden_when_only_other_ai_features_are_available()
{
AiApi.Status = new AiStatusDto(true, false, true);
var cut = OpenOverview();
cut.WaitForAssertion(() => Assert.True(AiApi.StatusCalls > 0));
Assert.Empty(cut.FindAll(".fp-learning-ai"));
}
[Fact]
public void A_failing_status_call_hides_the_button_without_breaking_the_page()
{
AiApi.Status = null; // 403 o. Ä.
var cut = OpenOverview();
cut.WaitForAssertion(() => Assert.True(AiApi.StatusCalls > 0));
Assert.Empty(cut.FindAll(".fp-learning-ai"));
Assert.Empty(cut.FindAll(".fp-learning-error"));
Assert.NotEmpty(cut.FindAll(".fp-learning-new"));
}
[Fact]
public void Button_is_shown_when_generation_is_available()
{
AiApi.Status = new AiStatusDto(true, true, false);
var cut = OpenOverview();
cut.WaitForAssertion(() => Assert.Contains("Mit KI erstellen", cut.Find(".fp-learning-ai").TextContent));
}
// ---------- Dialog ----------
[Fact]
public void Dialog_creates_a_draft_with_the_entered_parameters_and_shows_the_report()
{
Api.Users.RemoveAll(u => u.Id == FakeLearningApi.Ben);
var cut = OpenDialog();
Assert.Empty(cut.FindAll(".fp-ai-privacy"));
cut.Find(".fp-ai-count input").Change("12");
cut.Find("#ai-extra").Input("Textaufgaben zum Einkaufen");
cut.Find(".fp-ai-create").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-ai-result")));
var request = Assert.Single(Api.AiDrafts);
Assert.Equal((LearningSubjects.Math, 3, FakeLearningApi.Mia, 12, LearningDifficultyCurve.EasyToHard, "Textaufgaben zum Einkaufen", LearningMode.Practice),
(request.Subject, request.GradeLevel, request.TargetUserId, request.Count, request.Difficulty, request.Extra, request.Mode));
Assert.Contains("mit 12 Aufgaben erstellt", cut.Find(".fp-ai-done").TextContent);
Assert.Contains("KI-generiert, bitte prüfen", cut.Find(".fp-ai-review-hint").TextContent);
Assert.Contains("2 Rechenaufgaben nachgerechnet", cut.Find(".fp-ai-verified").TextContent);
Assert.Contains("1 Rechenaufgabe konnte nicht nachgerechnet werden", cut.Find(".fp-ai-notverified").TextContent);
Assert.Contains("1 Aufgabe verworfen", cut.Find(".fp-ai-discarded").TextContent);
Assert.Equal(2, cut.FindAll(".fp-ai-notes li").Count);
}
[Fact]
public void Opening_the_editor_navigates_to_the_draft()
{
Api.Users.RemoveAll(u => u.Id == FakeLearningApi.Ben);
var cut = OpenDialog();
cut.Find(".fp-ai-create").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-ai-open")));
cut.Find(".fp-ai-open").Click();
var draft = Api.Sets.Single(s => s.Source == LearningSource.Ai);
cut.WaitForAssertion(() => Assert.EndsWith($"lernen/blatt/{draft.Id}", CurrentUri));
}
[Fact]
public void Closing_the_report_later_keeps_the_draft_in_the_list()
{
Api.Users.RemoveAll(u => u.Id == FakeLearningApi.Ben);
var cut = OpenDialog();
cut.Find(".fp-ai-create").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-ai-later")));
cut.Find(".fp-ai-later").Click();
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".mud-dialog")));
Assert.DoesNotContain("lernen/blatt/", CurrentUri);
cut.WaitForAssertion(() => Assert.Contains("KI-Entwurf", cut.Find(".fp-learning-list").TextContent));
Assert.NotEmpty(cut.FindAll(".fp-learning-ai-chip"));
}
[Fact]
public void Count_is_limited_to_the_allowed_range()
{
var cut = OpenDialog();
cut.Find(".fp-ai-count input").Change("99");
cut.Find(".fp-ai-create").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-ai-result")));
Assert.Equal(LearningAiDraft.MaxCount, Api.AiDrafts.Single().Count);
}
[Fact]
public void Without_children_the_assistant_cannot_create_anything()
{
Api.Users.RemoveAll(u => u.Role == Roles.Child);
var cut = OpenDialog();
Assert.True(cut.Find(".fp-ai-create").HasAttribute("disabled"));
Assert.Empty(Api.AiDrafts);
}
[Fact]
public void Extra_text_is_limited_in_length()
{
var cut = OpenDialog();
Assert.Equal(LearningAiDraft.ExtraMaxLength.ToString(), cut.Find("#ai-extra").GetAttribute("maxlength"));
}
[Fact]
public void Cloud_providers_get_a_privacy_notice()
{
var cut = OpenDialog(cloud: true);
Assert.Contains("außerhalb des Heimnetzes", cut.Find(".fp-ai-privacy").TextContent);
}
[Fact]
public void Server_messages_are_shown_and_the_dialog_stays_open_for_another_try()
{
Api.AiDraftBehavior = _ => ApiResult<LearningAiDraftDto>.Fail(new ApiProblem { Status = 429, Detail = "Das Tageslimit von 5 KI-Aufrufen ist erreicht. Morgen geht es weiter." });
var cut = OpenDialog();
cut.Find(".fp-ai-create").Click();
cut.WaitForAssertion(() => Assert.Contains("Tageslimit von 5 KI-Aufrufen", cut.Find(".fp-dialog-error").TextContent));
Assert.NotEmpty(cut.FindAll(".fp-ai-create"));
Assert.Empty(cut.FindAll(".fp-ai-result"));
}
[Fact]
public void Waiting_state_is_shown_while_the_ai_works_and_inputs_are_locked()
{
var gate = new TaskCompletionSource();
Api.AiDraftGate = gate;
var cut = OpenDialog();
cut.Find(".fp-ai-create").Click();
cut.WaitForAssertion(() => Assert.Contains("Die KI erstellt das Blatt", cut.Find(".fp-ai-busy").TextContent));
Assert.True(cut.Find(".fp-ai-create").HasAttribute("disabled"));
Assert.True(cut.Find(".fp-ai-cancel").HasAttribute("disabled"));
gate.SetResult();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-ai-result")));
}
[Fact]
public void Ai_texts_are_rendered_as_text_never_as_html()
{
Api.AiDraftBehavior = request =>
{
var detail = Api.AddSet("<b>fett</b> Titel", itemCount: 1, child: request.TargetUserId);
return ApiResult<LearningAiDraftDto>.Ok(new LearningAiDraftDto(detail, 1, 0, 0, 0, ["<img src=x onerror=alert(1)> Hinweis"]));
};
var cut = OpenDialog();
cut.Find(".fp-ai-create").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-ai-notes")));
Assert.Empty(cut.FindAll(".fp-ai-notes img"));
Assert.Empty(cut.FindAll(".fp-ai-done b"));
Assert.Contains("<img src=x", cut.Find(".fp-ai-notes li").TextContent);
}
// ---------- Editor ----------
private IRenderedComponent<LearningHost> OpenEditor(Guid id)
{
SignInAs(Roles.Family, "Papa", FakeLearningApi.Me);
return Render<LearningHost>(p => p.AddChildContent<SetEditor>(c => c.Add(x => x.SetId, id)));
}
private LearningSetDetailDto AiSet(LearningSetStatus status)
{
var detail = Api.AddSet("KI-Blatt", status);
var set = detail.Set with { Source = LearningSource.Ai, AiPrompt = "Mathe, Klasse 3 – Wunsch: <i>Pizza</i>", AiProvider = "Fake-KI", AiModel = "fake-1" };
detail = detail with { Set = set };
Api.Details[set.Id] = detail;
Api.Sets[Api.Sets.FindIndex(s => s.Id == set.Id)] = set;
return detail;
}
[Fact]
public void Editor_warns_that_an_ai_draft_must_be_checked_and_shows_the_origin_as_text()
{
var detail = AiSet(LearningSetStatus.Draft);
var cut = OpenEditor(detail.Set.Id);
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-ai-notice")));
var notice = cut.Find(".fp-ai-notice");
Assert.Contains("KI-generiert, bitte prüfen.", notice.TextContent);
Assert.Contains("Erstellt mit Fake-KI (fake-1)", notice.TextContent);
Assert.Contains("Wunsch: <i>Pizza</i>", cut.Find(".fp-ai-prompt").TextContent); // Text, kein Markup
Assert.Empty(cut.FindAll(".fp-ai-prompt i"));
}
[Fact]
public void Assigned_ai_sheets_keep_a_short_origin_note_without_the_review_warning()
{
var detail = AiSet(LearningSetStatus.Assigned);
var cut = OpenEditor(detail.Set.Id);
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-ai-notice")));
Assert.DoesNotContain("bitte prüfen", cut.Find(".fp-ai-notice").TextContent);
}
[Fact]
public void Manual_sheets_have_no_ai_notice()
{
var detail = Api.AddSet("Manuell");
var cut = OpenEditor(detail.Set.Id);
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-set-items")));
Assert.Empty(cut.FindAll(".fp-ai-notice"));
}
}
@@ -209,6 +209,36 @@ internal sealed class FakeLearningApi : ILearningApi
return Result(() => detail);
}
// ---- KI-Entwurf (Phase 10b) ----
public List<CreateAiDraftRequest> AiDrafts { get; } = [];
/// <summary>Antwort auf den KI-Entwurf; Standard: Entwurf mit der gewünschten Anzahl Aufgaben und einem Prüfbericht.</summary>
public Func<CreateAiDraftRequest, ApiResult<LearningAiDraftDto>>? AiDraftBehavior { get; set; }
/// <summary>Wird beim KI-Entwurf abgewartet (zum Prüfen des Wartezustands); <c>null</c> = sofort.</summary>
public TaskCompletionSource? AiDraftGate { get; set; }
public async Task<ApiResult<LearningAiDraftDto>> CreateAiDraftAsync(CreateAiDraftRequest request, CancellationToken ct = default)
{
AiDrafts.Add(request);
if (AiDraftGate is { } gate)
{
await gate.Task;
}
if (AiDraftBehavior is { } behavior)
{
return behavior(request);
}
var detail = AddSet($"{request.Subject} KI-Entwurf", child: request.TargetUserId, itemCount: request.Count, mode: request.Mode);
var set = detail.Set with { Source = LearningSource.Ai, AiPrompt = $"{request.Subject}, Klasse {request.GradeLevel}", AiProvider = "Fake-KI", AiModel = "fake-1" };
detail = detail with { Set = set };
Details[set.Id] = detail;
Sets[Sets.FindIndex(s => s.Id == set.Id)] = set;
return ApiResult<LearningAiDraftDto>.Ok(new LearningAiDraftDto(detail, request.Count, 1, 2, 1, ["Aufgabe 2 verworfen: kommt doppelt vor.", "Aufgabe 5: Die Lösung wurde nicht nachgerechnet (kein Rechenausdruck). Bitte selbst prüfen."]));
}
public Task<ApiResult<LearningSetDetailDto>> UpdateSetAsync(Guid id, UpdateLearningSetRequest request, CancellationToken ct = default)
{
Updated.Add((id, request));
@@ -1,7 +1,9 @@
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Shared.Ai;
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;
@@ -14,6 +16,9 @@ public abstract class LearningTestBase : BunitContext
{
internal FakeLearningApi Api { get; } = new();
/// <summary>Standard: keine KI eingerichtet. Tests für Phase 10b setzen <see cref="FakeAiApi.Status"/>.</summary>
internal FakeAiApi AiApi { get; } = new() { Status = new AiStatusDto(false, false, false) };
internal FakeLiveConnection Live { get; } = new();
internal ManualTimeProvider Time { get; } = new(FakeLearningApi.Now);
@@ -23,6 +28,7 @@ public abstract class LearningTestBase : BunitContext
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddMudServices();
Services.AddSingleton<ILearningApi>(Api);
Services.AddSingleton<IAiApi>(AiApi);
Services.AddSingleton<ILiveConnection>(Live);
Services.AddSingleton<IAuthService, NoopAuthService>();
Services.AddSingleton<ApiAuthenticationStateProvider>();