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:
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>();
|
||||
|
||||
Reference in new issue
Block a user