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; /// Fehler beim Aufruf des KI-Backends, Message ist bereits deutsch und nutzergerichtet. public class AiBackendException(string userMessage) : Exception(userMessage); /// /// 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. /// public class GermanDateOnlyJsonConverter : JsonConverter { 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)); } /// Analog zu , für "HH:mm" statt ISO-Zeiten. public class GermanTimeOnlyJsonConverter : JsonConverter { 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)); } /// /// Orchestriert die KI-gestützte Planungsunterstützung (TODO 4.5.9): baut aus einer /// 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. /// 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() }, }; public async Task 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(JsonOptions); return result?.Token ?? throw new AiBackendException("Unerwartete Antwort des KI-Dienstes."); } public async Task 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(JsonOptions); return result?.BalanceUsd ?? 0m; } /// Rein (nur Repository-Lesezugriffe, kein Netzwerk) — testbar mit Fakes. /// /// Noch nicht gespeicherter Entwurf aus einer vorherigen Antwortrunde (Nachfassen mit /// geänderter Anweisung, siehe ) — überschreibt einzelne Lessons im /// gesendeten Kontext, ohne dass dafür etwas in der Datenbank gespeichert werden muss. /// public AiUnitContext BuildContext(Unit unit, string instruction, List? 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, 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 ToAiPhases(List phases, Dictionary 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 a, List 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; } /// /// 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. /// public List DescribeChanges(Lesson existing, AiLesson proposed) { var diffs = new List(); 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"); 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; } /// /// Baut aus Einheit/Stunde/Phase und dem KI-Medienvorschlag () /// 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. /// 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 { $"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); } /// /// Ersetzt in (dem tatsächlichen Datenbankstand) jede Lesson, deren Id /// auch in 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. /// private static List MergeDraft(List saved, List 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 RequestPlanAsync(Unit unit, string instruction, string token, bool allowModifyingExistingLessons = true, List? draftOverrides = null) { var request = new AiPlanningRequest { Instruction = instruction, Unit = BuildContext(unit, instruction, draftOverrides), AllowModifyingExistingLessons = allowModifyingExistingLessons, }; 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 (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(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."); } } /// /// 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 /// 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 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. /// public List ApplyResponse(Unit unit, List acceptedLessons, bool allowModifyingExistingLessons = true) { 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(); foreach (var ai in acceptedLessons) { 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, // 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, AlternativePathId = p.AlternativePathName is { } name && pathIdsByName.TryGetValue(name, out var pathId) ? pathId : null, }).ToList(), }); } return result; } private class LoginResult { public string Token { get; set; } = ""; } private class BalanceResult { public decimal BalanceUsd { get; set; } } }