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:
@@ -339,6 +339,47 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fragt einen didaktischen Hintergrund zu einer bereits vorgeschlagenen/geplanten Lesson ab
|
||||
/// (4.5.21 "Schattenfeld") — eigener Endpunkt (ai-backend/explain.php), damit das nur bei
|
||||
/// tatsächlicher Nutzung abgerechnet wird statt bei jeder plan.php-Antwort mitgeneriert zu
|
||||
/// werden. Ändert nichts an der Lesson, liefert nur erklärenden Text.
|
||||
/// </summary>
|
||||
public async Task<string> RequestExplanationAsync(Unit unit, AiLesson lesson, string token)
|
||||
{
|
||||
var request = new AiExplainRequest { Unit = BuildContext(unit, ""), Lesson = lesson };
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "explain.php")
|
||||
{
|
||||
Content = JsonContent.Create(request, options: JsonOptions),
|
||||
};
|
||||
req.Headers.Authorization = new("Bearer", token);
|
||||
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await http.SendAsync(req); }
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
||||
}
|
||||
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||||
throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
|
||||
if (resp.StatusCode == (HttpStatusCode)402)
|
||||
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new AiBackendException("Die Anfrage an den KI-Dienst ist fehlgeschlagen.");
|
||||
|
||||
try
|
||||
{
|
||||
var result = await resp.Content.ReadFromJsonAsync<AiExplainResponse>(JsonOptions);
|
||||
return result?.Explanation ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
|
||||
}
|
||||
catch (Exception ex) when (ex is not AiBackendException)
|
||||
{
|
||||
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rein (nur Repository-Lesezugriff für den Alternativpfad-Katalog, kein Schreiben) — testbar
|
||||
/// mit Fakes. Gibt die zu speichernden Lesson-Objekte zurück; der Aufrufer ruft
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -38,6 +38,20 @@
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Content="💡 Didaktischen Hintergrund erklären" FontSize="11" Margin="24,2,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding RequestExplanationCommand}"
|
||||
IsVisible="{Binding ShowExplanationButton}"
|
||||
IsEnabled="{Binding !IsLoadingExplanation}"/>
|
||||
<TextBlock Text="Lädt…" FontSize="11" Opacity="0.6" Margin="24,0,0,0"
|
||||
IsVisible="{Binding IsLoadingExplanation}"/>
|
||||
<TextBlock Text="{Binding ExplanationError}" Foreground="Red" FontSize="11" TextWrapping="Wrap"
|
||||
Margin="24,0,0,0"
|
||||
IsVisible="{Binding ExplanationError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<Border Background="#0A000000" CornerRadius="4" Padding="6" Margin="24,2,0,0"
|
||||
IsVisible="{Binding Explanation, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Explanation}" FontSize="11" TextWrapping="Wrap"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
|
||||
Reference in New Issue
Block a user