CI / build-and-test (push) Canceled after 0s
Lesson bekommt ein neues Freitextfeld PlanningIdeas fuer grobe Ideen vor der Feinplanung, sichtbar im LessonDialog und als Kontext an die KI-Planung (Backend + MCP) durchgereicht. LessonPhaseStep.MaterialPrompt speichert den beim Uebernehmen einer KI-Stunde erzeugten Materialerstellungs-Prompt dauerhaft, statt ihn nur einmalig im AiAssistDialog anzuzeigen - im Verlaufsplan-Editor ueber einen neuen Kopieren-Button je Phase erneut nutzbar, auch ueber MCP lesbar/schreibbar. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
678 lines
33 KiB
C#
678 lines
33 KiB
C#
using LehrerApp.Core.AiPlanning;
|
||
using LehrerApp.Core.Interfaces;
|
||
using LehrerApp.Core.Models;
|
||
using System.Globalization;
|
||
using System.Net;
|
||
using System.Net.Http.Json;
|
||
using System.Text.Json;
|
||
using System.Text.Json.Serialization;
|
||
|
||
namespace LehrerApp.Desktop.Services;
|
||
|
||
/// <summary>Fehler beim Aufruf des KI-Backends, Message ist bereits deutsch und nutzergerichtet.</summary>
|
||
public class AiBackendException(string userMessage, string? rawResponse = null) : Exception(userMessage)
|
||
{
|
||
/// <summary>
|
||
/// Unveränderte Modellantwort, sofern der Fehler beim Lesen einer Planungsantwort entstand.
|
||
/// Sie wird ausschließlich für den ausdrücklich vom Nutzer gestarteten Rettungsdialog gehalten.
|
||
/// </summary>
|
||
public string? RawResponse { get; } = rawResponse;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Der KI-Wire-Vertrag verwendet deutsches Datumsformat (siehe ai-backend/plan.php Systemprompt,
|
||
/// Abschnitt "Eingabeschema"/"Antwortformat"), nicht .NETs Standardformat für DateOnly (ISO
|
||
/// "yyyy-MM-dd") — ohne diesen Converter würde jede im dokumentierten Format zurückgegebene
|
||
/// KI-Antwort beim Deserialisieren mit einer JsonException scheitern. Fällt defensiv auf
|
||
/// allgemeines Parsen zurück, falls die KI sich nicht exakt ans Format hält, statt hart zu
|
||
/// scheitern — LLMs weichen erfahrungsgemäß gelegentlich vom dokumentierten Format ab.
|
||
/// </summary>
|
||
public class GermanDateOnlyJsonConverter : JsonConverter<DateOnly>
|
||
{
|
||
private const string Format = "dd.MM.yyyy";
|
||
|
||
public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||
{
|
||
var value = reader.GetString() ?? "";
|
||
if (DateOnly.TryParseExact(value, Format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var exact))
|
||
return exact;
|
||
if (DateOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var fallback))
|
||
return fallback;
|
||
throw new JsonException($"Datum \"{value}\" konnte nicht gelesen werden (erwartet: {Format}).");
|
||
}
|
||
|
||
public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options) =>
|
||
writer.WriteStringValue(value.ToString(Format, CultureInfo.InvariantCulture));
|
||
}
|
||
|
||
/// <summary>Analog zu <see cref="GermanDateOnlyJsonConverter"/>, für "HH:mm" statt ISO-Zeiten.</summary>
|
||
public class GermanTimeOnlyJsonConverter : JsonConverter<TimeOnly>
|
||
{
|
||
private const string Format = "HH:mm";
|
||
|
||
public override TimeOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||
{
|
||
var value = reader.GetString() ?? "";
|
||
if (TimeOnly.TryParseExact(value, Format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var exact))
|
||
return exact;
|
||
if (TimeOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var fallback))
|
||
return fallback;
|
||
throw new JsonException($"Uhrzeit \"{value}\" konnte nicht gelesen werden (erwartet: {Format}).");
|
||
}
|
||
|
||
public override void Write(Utf8JsonWriter writer, TimeOnly value, JsonSerializerOptions options) =>
|
||
writer.WriteStringValue(value.ToString(Format, CultureInfo.InvariantCulture));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Orchestriert die KI-gestützte Planungsunterstützung (TODO 4.5.9): baut aus einer <see cref="Unit"/>
|
||
/// den Export-Kontext, ruft das externe PHP-Backend (ai-backend/) auf und wendet dessen Antwort auf
|
||
/// die Lessons an. Das Backend selbst ruft serverseitig eine LLM-API auf — der Desktop-Client sieht
|
||
/// nie einen LLM-API-Key, nur das eigene Bearer-Token gegen das PHP-Backend.
|
||
/// </summary>
|
||
public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||
IGroupRepository groups, ISubjectRepository subjects,
|
||
ICompetencyDomainRepository competencyDomains, IAlternativeLessonPathRepository altPaths)
|
||
{
|
||
// Wire-Format zum PHP-Backend ist camelCase (siehe ai-backend/) — beide Seiten sind hier
|
||
// im eigenen Zugriff, daher bewusst konsistent camelCase statt der C#-üblichen PascalCase-Defaults.
|
||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||
{
|
||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||
PropertyNameCaseInsensitive = true,
|
||
Converters = { new GermanDateOnlyJsonConverter(), new GermanTimeOnlyJsonConverter() },
|
||
};
|
||
|
||
/// <summary>
|
||
/// Baut eine nutzergerichtete Fehlermeldung für eine fehlgeschlagene Backend-Antwort, die
|
||
/// weder 401 noch 402 war (die haben eigene, feste Meldungen). Liest den vom Backend
|
||
/// gesendeten Klartext-Grund (<c>ai_backend_fail</c> liefert immer <c>{"error": "..."}</c>)
|
||
/// mit, statt nur eine pauschale Meldung zu zeigen — ohne das war z.B. beim Fehlschlagen der
|
||
/// Websuche in substance.php (falscher Tool-Bezeichner, fehlender Beta-Header o.ä.) der
|
||
/// eigentliche Grund nirgends sichtbar, nur "Die Anfrage ist fehlgeschlagen".
|
||
/// </summary>
|
||
private static async Task<AiBackendException> BuildRequestFailedExceptionAsync(HttpResponseMessage resp)
|
||
{
|
||
string? backendReason = null;
|
||
string? rawResponse = null;
|
||
try
|
||
{
|
||
var responseText = await resp.Content.ReadAsStringAsync();
|
||
var body = JsonSerializer.Deserialize<BackendErrorResult>(responseText, JsonOptions);
|
||
backendReason = string.IsNullOrWhiteSpace(body?.Error) ? null : body!.Error;
|
||
rawResponse = string.IsNullOrWhiteSpace(body?.RawResponse) ? null : body!.RawResponse;
|
||
}
|
||
catch { /* Antwortkörper war kein valides {"error": "..."}-JSON - Fallback unten greift. */ }
|
||
|
||
return new AiBackendException(backendReason is null
|
||
? "Die Anfrage an den KI-Dienst ist fehlgeschlagen."
|
||
: $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}", rawResponse);
|
||
}
|
||
|
||
public async Task<string> LoginAsync(string username, string password)
|
||
{
|
||
HttpResponseMessage resp;
|
||
try
|
||
{
|
||
resp = await http.PostAsJsonAsync("login.php", new { username, password }, JsonOptions);
|
||
}
|
||
catch (HttpRequestException)
|
||
{
|
||
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
||
}
|
||
|
||
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||
throw new AiBackendException("Benutzername oder Passwort ist falsch.");
|
||
if (!resp.IsSuccessStatusCode)
|
||
throw new AiBackendException("Anmeldung fehlgeschlagen. Bitte später erneut versuchen.");
|
||
|
||
var result = await resp.Content.ReadFromJsonAsync<LoginResult>(JsonOptions);
|
||
return result?.Token ?? throw new AiBackendException("Unerwartete Antwort des KI-Dienstes.");
|
||
}
|
||
|
||
public async Task<decimal> GetBalanceAsync(string token)
|
||
{
|
||
using var req = new HttpRequestMessage(HttpMethod.Get, "status.php");
|
||
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.IsSuccessStatusCode)
|
||
throw new AiBackendException("Guthaben konnte nicht abgerufen werden.");
|
||
|
||
var result = await resp.Content.ReadFromJsonAsync<BalanceResult>(JsonOptions);
|
||
return result?.BalanceUsd ?? 0m;
|
||
}
|
||
|
||
/// <summary>Rein (nur Repository-Lesezugriffe, kein Netzwerk) — testbar mit Fakes.</summary>
|
||
/// <param name="draftOverrides">
|
||
/// Noch nicht gespeicherter Entwurf aus einer vorherigen Antwortrunde (Nachfassen mit
|
||
/// geänderter Anweisung, siehe <see cref="MergeDraft"/>) — überschreibt einzelne Lessons im
|
||
/// gesendeten Kontext, ohne dass dafür etwas in der Datenbank gespeichert werden muss.
|
||
/// </param>
|
||
public AiUnitContext BuildContext(Unit unit, string instruction, List<AiLesson>? draftOverrides = null)
|
||
{
|
||
var group = groups.GetById(unit.GroupId);
|
||
var subject = group?.SubjectId is { } subjectId ? subjects.GetById(subjectId) : null;
|
||
var gradeLevel = group?.GradeLevel ?? 0;
|
||
|
||
var competencyCatalog = subject is null
|
||
? []
|
||
: competencyDomains.GetBySubjectAndGrade(subject.Id, gradeLevel)
|
||
.Select(d => new AiCompetencyDomain
|
||
{
|
||
Name = d.Name,
|
||
Items = d.Items.Select(i => new AiCompetencyItem { Code = i.Code, Description = i.Description }).ToList(),
|
||
})
|
||
.ToList();
|
||
|
||
var pathCatalog = altPaths.GetAll()
|
||
.Select(p => new AiAlternativePath { Id = p.Id, Name = p.Name })
|
||
.ToList();
|
||
var pathNames = altPaths.GetAll().ToDictionary(p => p.Id, p => p.Name);
|
||
|
||
var unitLessons = lessons.GetByUnit(unit.Id)
|
||
.Select(l => new AiLesson
|
||
{
|
||
Id = l.Id,
|
||
Date = l.Date,
|
||
LessonNumber = l.LessonNumber,
|
||
Topic = l.Topic,
|
||
StartTime = l.StartTime,
|
||
Homework = l.Homework,
|
||
Reflection = l.Reflection,
|
||
PlanningIdeas = l.PlanningIdeas,
|
||
Phases = ToAiPhases(l.Phases, pathNames),
|
||
})
|
||
.ToList();
|
||
|
||
if (draftOverrides is not null)
|
||
unitLessons = MergeDraft(unitLessons, draftOverrides);
|
||
|
||
return new AiUnitContext
|
||
{
|
||
Id = unit.Id,
|
||
Title = unit.Title,
|
||
StartDate = unit.StartDate,
|
||
EndDate = unit.EndDate,
|
||
Competencies = unit.Competencies,
|
||
Notes = unit.Notes,
|
||
SubjectName = subject?.Name ?? "",
|
||
GradeLevel = gradeLevel,
|
||
GroupName = group?.Name ?? "",
|
||
CompetencyCatalog = competencyCatalog,
|
||
AlternativePathCatalog = pathCatalog,
|
||
Lessons = unitLessons,
|
||
};
|
||
}
|
||
|
||
private static List<AiPhaseStep> ToAiPhases(List<LessonPhaseStep> phases, Dictionary<Guid, string> pathNames) =>
|
||
phases.Select(p => new AiPhaseStep
|
||
{
|
||
Name = p.Name,
|
||
DurationMinutes = p.DurationMinutes,
|
||
Activity = p.Activity,
|
||
Material = p.Material,
|
||
Shorthand = p.Shorthand,
|
||
AlternativePathName = p.AlternativePathId is { } pathId ? pathNames.GetValueOrDefault(pathId) : null,
|
||
}).ToList();
|
||
|
||
private static bool PhasesEqual(List<AiPhaseStep> a, List<AiPhaseStep> b)
|
||
{
|
||
if (a.Count != b.Count) return false;
|
||
for (var i = 0; i < a.Count; i++)
|
||
{
|
||
var x = a[i]; var y = b[i];
|
||
if (x.Name != y.Name || x.DurationMinutes != y.DurationMinutes || x.Activity != y.Activity
|
||
|| x.Material != y.Material || x.Shorthand != y.Shorthand || x.AlternativePathName != y.AlternativePathName)
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Vergleicht eine bestehende Lesson mit dem KI-Vorschlag für dieselbe Id auf Feldebene und
|
||
/// beschreibt die Unterschiede in kurzen, menschenlesbaren Sätzen — Grundlage für die
|
||
/// Detailanzeige im AiAssistDialog (4.5.14 Planungsdiff), statt Änderungen nur pauschal als
|
||
/// "Geändert" zu markieren. Nur echte Unterschiede werden aufgeführt, Felder ohne Änderung
|
||
/// tauchen nicht auf. Rein (kein I/O außer dem bereits im Speicher gehaltenen Alternativpfad-
|
||
/// Katalog), daher ohne echtes Deployment testbar.
|
||
/// </summary>
|
||
public List<string> DescribeChanges(Lesson existing, AiLesson proposed)
|
||
{
|
||
var diffs = new List<string>();
|
||
|
||
if (!string.Equals(existing.Topic, proposed.Topic, StringComparison.Ordinal))
|
||
diffs.Add($"Thema: „{existing.Topic}“ → „{proposed.Topic}“");
|
||
|
||
if (proposed.Date is { } date && date != existing.Date)
|
||
diffs.Add($"Datum: {existing.Date:dd.MM.yyyy} → {date:dd.MM.yyyy}");
|
||
|
||
if (proposed.StartTime != existing.StartTime)
|
||
{
|
||
var oldText = existing.StartTime?.ToString("HH:mm") ?? "kein Beginn";
|
||
var newText = proposed.StartTime?.ToString("HH:mm") ?? "kein Beginn";
|
||
diffs.Add($"Beginn: {oldText} → {newText}");
|
||
}
|
||
|
||
if (proposed.LessonNumber != existing.LessonNumber)
|
||
diffs.Add($"Stundennummer: {existing.LessonNumber?.ToString() ?? "–"} → {proposed.LessonNumber?.ToString() ?? "–"}");
|
||
|
||
if (!string.Equals(existing.Homework, proposed.Homework, StringComparison.Ordinal))
|
||
diffs.Add("Hausaufgabe geändert");
|
||
|
||
if (!string.Equals(existing.Reflection, proposed.Reflection, StringComparison.Ordinal))
|
||
diffs.Add("Reflexion geändert");
|
||
|
||
if (!string.Equals(existing.PlanningIdeas, proposed.PlanningIdeas, StringComparison.Ordinal))
|
||
diffs.Add("Planungsideen geändert");
|
||
|
||
var pathNames = altPaths.GetAll().ToDictionary(p => p.Id, p => p.Name);
|
||
var existingPhases = ToAiPhases(existing.Phases, pathNames);
|
||
if (!PhasesEqual(existingPhases, proposed.Phases))
|
||
{
|
||
diffs.Add(existingPhases.Count == proposed.Phases.Count
|
||
? "Verlaufsplan geändert"
|
||
: $"Verlaufsplan geändert ({existingPhases.Count} → {proposed.Phases.Count} Phase(n))");
|
||
}
|
||
|
||
return diffs;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Baut aus Einheit/Stunde/Phase und dem KI-Medienvorschlag (<see cref="AiPhaseStep.MaterialSuggestion"/>)
|
||
/// einen vollständigen, eigenständigen Prompt zum Einfügen in eine separate Claude-Sitzung —
|
||
/// die Lehrkraft kann sich das eigentliche Material (Tafelbild, Arbeitsblatt, ...) dort selbst
|
||
/// erzeugen lassen, ohne dass das über das eigene (kostenpflichtige) KI-Backend laufen muss.
|
||
/// Nur der kurze Vorschlagstext kommt von der KI, der restliche Kontext wird rein lokal aus
|
||
/// bereits vorhandenen Daten zusammengesetzt. Rein, ohne I/O.
|
||
/// </summary>
|
||
public string BuildMaterialPrompt(Unit unit, AiLesson lesson, AiPhaseStep phase)
|
||
{
|
||
var group = groups.GetById(unit.GroupId);
|
||
var subject = group?.SubjectId is { } subjectId ? subjects.GetById(subjectId) : null;
|
||
var dateText = lesson.Date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "noch offen";
|
||
|
||
var lines = new List<string>
|
||
{
|
||
$"Ich unterrichte {subject?.Name ?? "unbekanntes Fach"}, Klassenstufe {group?.GradeLevel ?? 0}, Gruppe {group?.Name ?? "-"}.",
|
||
$"Einheit: „{unit.Title}“",
|
||
$"Stunde: „{lesson.Topic}“ (Datum: {dateText})",
|
||
$"Phase: „{phase.Name}“, {phase.DurationMinutes} Min., Tätigkeit: {phase.Activity}",
|
||
};
|
||
if (!string.IsNullOrWhiteSpace(phase.Material))
|
||
lines.Add($"Bisheriges Material laut Planung: {phase.Material}");
|
||
lines.Add("");
|
||
lines.Add("Empfehlung aus der Unterrichtsplanung, was das Material zeigen/enthalten sollte:");
|
||
lines.Add(phase.MaterialSuggestion ?? "");
|
||
lines.Add("");
|
||
lines.Add("Bitte erstelle mir auf dieser Grundlage ein passendes Material für diese Phase " +
|
||
"(z.B. Tafelbild, Arbeitsblatt oder Visualisierung).");
|
||
|
||
return string.Join("\n", lines);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Ersetzt in <paramref name="saved"/> (dem tatsächlichen Datenbankstand) jede Lesson, deren Id
|
||
/// auch in <paramref name="draft"/> vorkommt, durch die Entwurfsversion, und hängt Entwürfe ohne
|
||
/// Id (neu vorgeschlagene Lessons) an. So sieht die KI beim Nachfassen den Stand inklusive der
|
||
/// zuvor vorgeschlagenen, aber noch nicht gespeicherten Änderungen — ohne dass dafür etwas in
|
||
/// der Datenbank landen muss, bevor der Nutzer "Übernehmen" klickt.
|
||
/// </summary>
|
||
private static List<AiLesson> MergeDraft(List<AiLesson> saved, List<AiLesson> draft)
|
||
{
|
||
var draftById = draft.Where(d => d.Id.HasValue).ToDictionary(d => d.Id!.Value);
|
||
var merged = saved.Select(l => l.Id.HasValue && draftById.TryGetValue(l.Id.Value, out var replacement)
|
||
? replacement : l).ToList();
|
||
merged.AddRange(draft.Where(d => !d.Id.HasValue));
|
||
return merged;
|
||
}
|
||
|
||
public async Task<AiPlanningResponse> RequestPlanAsync(Unit unit, string instruction, string token,
|
||
bool allowModifyingExistingLessons = true, List<AiLesson>? draftOverrides = null, Guid? focusLessonId = null)
|
||
{
|
||
var request = new AiPlanningRequest
|
||
{
|
||
Instruction = instruction,
|
||
Unit = BuildContext(unit, instruction, draftOverrides),
|
||
AllowModifyingExistingLessons = allowModifyingExistingLessons,
|
||
FocusLessonId = focusLessonId,
|
||
};
|
||
|
||
using var req = new HttpRequestMessage(HttpMethod.Post, "plan.php")
|
||
{
|
||
Content = JsonContent.Create(request, options: JsonOptions),
|
||
};
|
||
req.Headers.Authorization = new("Bearer", token);
|
||
|
||
HttpResponseMessage resp;
|
||
try { resp = await http.SendAsync(req); }
|
||
catch (TaskCanceledException)
|
||
{
|
||
throw new AiBackendException(
|
||
"Die KI-Anfrage hat länger als 3½ Minuten gedauert und wurde beendet. " +
|
||
"Falls das wiederholt passiert, bitte das Zeitlimit des Webserver-Proxys prüfen.");
|
||
}
|
||
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 await BuildRequestFailedExceptionAsync(resp);
|
||
|
||
var responseText = await resp.Content.ReadAsStringAsync();
|
||
try
|
||
{
|
||
var result = JsonSerializer.Deserialize<AiPlanningResponse>(responseText, JsonOptions);
|
||
return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
|
||
}
|
||
catch (Exception ex) when (ex is not AiBackendException)
|
||
{
|
||
string? rawModelResponse = null;
|
||
try
|
||
{
|
||
using var envelope = JsonDocument.Parse(responseText);
|
||
if (envelope.RootElement.TryGetProperty("rawResponse", out var rawElement)
|
||
&& rawElement.ValueKind == JsonValueKind.String)
|
||
rawModelResponse = rawElement.GetString();
|
||
}
|
||
catch (JsonException) { /* Die HTTP-Antwort selbst war ungültig; unten komplett zeigen. */ }
|
||
|
||
throw new AiBackendException(
|
||
"Die Antwort der KI konnte nicht verarbeitet werden. Du kannst die Antwort manuell retten.",
|
||
string.IsNullOrWhiteSpace(rawModelResponse) ? responseText : rawModelResponse);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Liest eine von Hand markierte Modellantwort. Akzeptiert die normale Antwort-Hülle, ein
|
||
/// einzelnes Lesson-Objekt oder ein Array von Lessons, damit auch nur der relevante Ausschnitt
|
||
/// der Rohantwort markiert werden kann.
|
||
/// </summary>
|
||
public static List<AiLesson> ParsePlanningLessons(string json)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(json))
|
||
throw new AiBackendException("Bitte markiere zuerst den JSON-Abschnitt der Antwort.");
|
||
|
||
try
|
||
{
|
||
using var document = JsonDocument.Parse(json);
|
||
var root = document.RootElement;
|
||
List<AiLesson>? parsed = root.ValueKind switch
|
||
{
|
||
JsonValueKind.Object when root.TryGetProperty("lessons", out _) =>
|
||
JsonSerializer.Deserialize<AiPlanningResponse>(json, JsonOptions)?.Lessons,
|
||
JsonValueKind.Object =>
|
||
[JsonSerializer.Deserialize<AiLesson>(json, JsonOptions)
|
||
?? throw new JsonException("Leere Stunde")],
|
||
JsonValueKind.Array => JsonSerializer.Deserialize<List<AiLesson>>(json, JsonOptions),
|
||
_ => null,
|
||
};
|
||
|
||
if (parsed is null || parsed.Count == 0)
|
||
throw new JsonException("Keine Stunde enthalten");
|
||
if (parsed.Any(l => string.IsNullOrWhiteSpace(l.Topic)))
|
||
throw new JsonException("Mindestens einer Stunde fehlt das Feld topic");
|
||
return parsed;
|
||
}
|
||
catch (JsonException ex)
|
||
{
|
||
throw new AiBackendException($"Der markierte Text ist noch kein gültiges Stunden-JSON: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
/// <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 await BuildRequestFailedExceptionAsync(resp);
|
||
|
||
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>
|
||
/// Fragt einen Entwurf für eine Gefährdungsbeurteilung zu genau dieser Lesson ab (Nutzerwunsch
|
||
/// neben 4.2 "Anhänge je Stunde"): die KI erkennt aus Thema/Verlaufsplan das Experiment und
|
||
/// liefert Stoffe, Gefährdungen, Schutzmaßnahmen etc. als Entwurf zur Weiterbearbeitung im
|
||
/// Gefährdungsbeurteilungs-Assistenten. Ändert nichts an der Lesson selbst.
|
||
/// </summary>
|
||
public async Task<AiHazardAssessmentResponse> RequestHazardAssessmentDraftAsync(Unit unit, AiLesson lesson, string token)
|
||
{
|
||
var request = new AiHazardAssessmentRequest { Unit = BuildContext(unit, ""), Lesson = lesson };
|
||
|
||
using var req = new HttpRequestMessage(HttpMethod.Post, "gbu.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 await BuildRequestFailedExceptionAsync(resp);
|
||
|
||
try
|
||
{
|
||
var result = await resp.Content.ReadFromJsonAsync<AiHazardAssessmentResponse>(JsonOptions);
|
||
return result ?? 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>
|
||
/// Fragt sicherheitsrelevante Daten zu einer Chemikalie ab (Nutzerwunsch neben 4.2): eigener
|
||
/// Endpunkt <c>ai-backend/substance.php</c>, serverseitig dateibasiert gecacht (Name/CAS,
|
||
/// Ablauffrist je nach Einstufung) — wiederholte Anfragen für denselben Stoff verursachen
|
||
/// nach dem ersten Mal keine weiteren Kosten mehr. Kein Unit-/Guppen-Kontext nötig, nur der
|
||
/// eingetippte Stoffname.
|
||
/// </summary>
|
||
public async Task<AiSubstanceResearchResponse> RequestSubstanceResearchAsync(string substanceName, string token)
|
||
{
|
||
using var req = new HttpRequestMessage(HttpMethod.Post, "substance.php")
|
||
{
|
||
Content = JsonContent.Create(new { name = substanceName }, 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 await BuildRequestFailedExceptionAsync(resp);
|
||
|
||
try
|
||
{
|
||
var result = await resp.Content.ReadFromJsonAsync<AiSubstanceResearchResponse>(JsonOptions);
|
||
return result ?? 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
|
||
/// <see cref="ILessonRepository.Save"/> je Eintrag auf. Eine akzeptierte AiLesson mit einer Id,
|
||
/// die keiner tatsächlich zur Einheit gehörenden Lesson entspricht, wird NIE als Update
|
||
/// interpretiert, sondern immer als neue Lesson behandelt (Anti-Halluzinations-Absicherung).
|
||
/// Ist <paramref name="allowModifyingExistingLessons"/> false, werden Änderungen an bestehenden
|
||
/// Lessons zusätzlich hart verworfen (nicht nur per Systemprompt an die KI erbeten) — die
|
||
/// Einschränkung wird also nicht blind der KI-Antwort überlassen. Ist
|
||
/// <paramref name="focusLessonId"/> gesetzt (Anfrage aus dem Editor einer einzelnen Stunde
|
||
/// heraus), wird ebenso hart jede Lesson mit abweichender oder fehlender Id verworfen.
|
||
/// </summary>
|
||
public List<Lesson> ApplyResponse(Unit unit, List<AiLesson> acceptedLessons,
|
||
bool allowModifyingExistingLessons = true, Guid? focusLessonId = null)
|
||
{
|
||
var pathIdsByName = altPaths.GetAll().ToDictionary(p => p.Name, p => p.Id);
|
||
var existingLessons = lessons.GetByUnit(unit.Id).ToDictionary(l => l.Id);
|
||
|
||
var result = new List<Lesson>();
|
||
foreach (var ai in acceptedLessons)
|
||
{
|
||
if (focusLessonId is { } focus && ai.Id != focus) continue;
|
||
var isUpdate = ai.Id is { } id && existingLessons.ContainsKey(id);
|
||
if (isUpdate && !allowModifyingExistingLessons) continue;
|
||
result.Add(new Lesson
|
||
{
|
||
Id = isUpdate ? ai.Id!.Value : Guid.NewGuid(),
|
||
UnitId = unit.Id,
|
||
GroupId = unit.GroupId,
|
||
Date = ai.Date ?? DateOnly.FromDateTime(DateTime.Today),
|
||
LessonNumber = ai.LessonNumber,
|
||
Topic = ai.Topic,
|
||
StartTime = ai.StartTime,
|
||
Homework = ai.Homework,
|
||
Reflection = ai.Reflection,
|
||
PlanningIdeas = ai.PlanningIdeas,
|
||
// Status bleibt bei einer Änderung erhalten — sonst würde eine bereits
|
||
// durchgeführte Stunde durch eine KI-Anpassung stillschweigend auf "Geplant"
|
||
// zurückgesetzt (die KI kennt/liefert diesen Status gar nicht).
|
||
Status = isUpdate ? existingLessons[ai.Id!.Value].Status : LessonStatus.Planned,
|
||
Phases = ai.Phases.Select(p => new LessonPhaseStep
|
||
{
|
||
Name = p.Name,
|
||
DurationMinutes = p.DurationMinutes,
|
||
Activity = p.Activity,
|
||
Material = p.Material,
|
||
Shorthand = p.Shorthand,
|
||
// Persistiert den Prompt (4.5.20/4.5.36), damit er nach dem Übernehmen weiterhin
|
||
// im Stundeneditor kopierbar bleibt statt nur einmalig im Review-Dialog.
|
||
MaterialPrompt = string.IsNullOrWhiteSpace(p.MaterialSuggestion)
|
||
? null : BuildMaterialPrompt(unit, ai, p),
|
||
AlternativePathId = p.AlternativePathName is { } name && pathIdsByName.TryGetValue(name, out var pathId)
|
||
? pathId : null,
|
||
}).ToList(),
|
||
});
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fragt für eine Charge anonymisierter Fehlzeitenzeilen (ai-backend/untis-status.php,
|
||
/// Nutzer-Feedback zum Untis-Hub) einen Statusvorschlag ab - eine Anfrage für alle fraglichen
|
||
/// Zeilen eines Abgleichslaufs statt einer je Zeile (Kosten/Latenz-Überlegung aus der
|
||
/// Nutzerdiskussion). Liefert nur dann Vorschläge zurück, wenn die vom Backend gemeldete
|
||
/// Id-Menge exakt der gesendeten entspricht (keine fehlenden, zusätzlichen oder doppelten Ids) -
|
||
/// andernfalls eine leere Zuordnung, statt sich auf eine möglicherweise vermischte Reihenfolge
|
||
/// zu verlassen. Der Aufrufer behält für jede nicht zurückgelieferte Id den bisherigen
|
||
/// regelbasierten Status bei.
|
||
/// </summary>
|
||
public async Task<IReadOnlyDictionary<string, string>> RequestUntisStatusSuggestionsAsync(
|
||
IReadOnlyList<AiUntisStatusRow> rows, string token)
|
||
{
|
||
if (rows.Count == 0) return new Dictionary<string, string>();
|
||
|
||
using var req = new HttpRequestMessage(HttpMethod.Post, "untis-status.php")
|
||
{
|
||
Content = JsonContent.Create(new AiUntisStatusRequest { Rows = rows.ToList() }, 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 await BuildRequestFailedExceptionAsync(resp);
|
||
|
||
AiUntisStatusResponse? result;
|
||
try { result = await resp.Content.ReadFromJsonAsync<AiUntisStatusResponse>(JsonOptions); }
|
||
catch (Exception ex) when (ex is not AiBackendException)
|
||
{
|
||
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
|
||
}
|
||
if (result is null)
|
||
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
|
||
|
||
var sentIds = rows.Select(r => r.Id).ToHashSet();
|
||
var receivedIds = result.Suggestions.Select(s => s.Id).ToList();
|
||
if (receivedIds.Count != sentIds.Count || receivedIds.Distinct().Count() != receivedIds.Count
|
||
|| !sentIds.SetEquals(receivedIds))
|
||
return new Dictionary<string, string>();
|
||
|
||
return result.Suggestions.ToDictionary(s => s.Id, s => s.Status);
|
||
}
|
||
|
||
private class LoginResult { public string Token { get; set; } = ""; }
|
||
private class BalanceResult { public decimal BalanceUsd { get; set; } }
|
||
private class BackendErrorResult
|
||
{
|
||
public string? Error { get; set; }
|
||
public string? RawResponse { get; set; }
|
||
}
|
||
}
|