KI-Feature 4.5.21: didaktischer Hintergrund je Stunde, nur auf Nachfrage

Neuer Endpunkt ai-backend/explain.php mit eigenem statischen Systemprompt
(Begründung des Phasenaufbaus, mögliche Stolpersteine, Differenzierungsideen).
Bewusst als separater Endpunkt statt Zusatzfeld in jeder plan.php-Antwort,
damit die Erklärung nur bei tatsächlicher Nutzung abgerechnet wird statt bei
jeder Planungsanfrage mitgeneriert zu werden.

Die Guthaben-Abrechnung (SELECT-FOR-UPDATE, Transaktions-Insert) wurde aus
plan.php nach ai_backend_call_and_charge in db.php ausgelagert, damit sie
nicht an zwei Stellen gepflegt werden muss. Kein neues DB-Schema nötig.

Im AiAssistDialog erscheint je Stunde ein Button "Didaktischen Hintergrund
erklären", der nach dem Laden durch den Text ersetzt wird.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 22:32:36 +02:00
co-authored by Claude Sonnet 5
parent f703fe7b66
commit 364df378f0
11 changed files with 333 additions and 79 deletions
@@ -1107,10 +1107,25 @@ public partial class AiLessonReviewItem : ObservableObject
/// AiPhaseStep.MaterialSuggestion) — leer, wenn die KI für keine Phase einen Vorschlag hatte.
public IReadOnlyList<MaterialPromptItem> MaterialPrompts { get; }
/// Ob für diese Lesson überhaupt ein "Hintergrund erklären"-Button angeboten wird (4.5.21) —
/// false z.B. in Tests/Kontexten ohne verdrahtete Abfragefunktion.
public bool CanRequestExplanation => _requestExplanation is not null;
/// Button verschwindet, sobald der Hintergrund einmal geladen wurde (Text steht dann da statt
/// des Buttons) — kein Grund, dieselbe kostenpflichtige Anfrage zweimal anzubieten.
public bool ShowExplanationButton => CanRequestExplanation && string.IsNullOrEmpty(Explanation);
partial void OnExplanationChanged(string value) => OnPropertyChanged(nameof(ShowExplanationButton));
private readonly Func<AiLesson, Task<string>>? _requestExplanation;
[ObservableProperty] private bool _accepted = true;
[ObservableProperty] private string _explanation = "";
[ObservableProperty] private bool _isLoadingExplanation;
[ObservableProperty] private string _explanationError = "";
public AiLessonReviewItem(AiLesson source, bool isNew, List<string>? fieldDiffs = null,
List<MaterialPromptItem>? materialPrompts = null)
List<MaterialPromptItem>? materialPrompts = null, Func<AiLesson, Task<string>>? requestExplanation = null)
{
Source = source;
IsNew = isNew;
@@ -1118,6 +1133,20 @@ public partial class AiLessonReviewItem : ObservableObject
DisplayLabel = isNew ? $"Neu: {source.Topic} ({dateText})" : $"Geändert: {source.Topic} ({dateText})";
DiffText = fieldDiffs is { Count: > 0 } ? string.Join("\n", fieldDiffs.Select(d => "• " + d)) : "";
MaterialPrompts = materialPrompts ?? [];
_requestExplanation = requestExplanation;
}
/// Holt den didaktischen Hintergrund erst auf Klick nach (4.5.21 "Schattenfeld") — eigener,
/// nur bei tatsächlicher Nutzung abgerechneter Endpunkt statt bei jeder Antwort mitgeneriert.
[RelayCommand]
private async Task RequestExplanation()
{
if (_requestExplanation is null || IsLoadingExplanation) return;
IsLoadingExplanation = true;
ExplanationError = "";
try { Explanation = await _requestExplanation(Source); }
catch (AiBackendException ex) { ExplanationError = ex.Message; }
finally { IsLoadingExplanation = false; }
}
}
@@ -1183,7 +1212,8 @@ public partial class AiAssistDialogViewModel : ObservableObject
.Where(p => !string.IsNullOrWhiteSpace(p.MaterialSuggestion))
.Select(p => new MaterialPromptItem(p.Name, p.MaterialSuggestion!, _aiPlanning.BuildMaterialPrompt(_unit, l, p)))
.ToList();
ReviewItems.Add(new AiLessonReviewItem(l, isNew: !isExisting, fieldDiffs, materialPrompts));
ReviewItems.Add(new AiLessonReviewItem(l, isNew: !isExisting, fieldDiffs, materialPrompts,
requestExplanation: aiLesson => _aiPlanning.RequestExplanationAsync(_unit, aiLesson, token)));
}
Summary = response.Summary;
HasResults = true;