diff --git a/.gitignore b/.gitignore index 1df22ea..57136dc 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,10 @@ server.txt # ── API / Docker ────────────────────────────────────────────────────────────── # Lokale Datenhaltung des Servers LehrerApp.Api/data/ + +# ── KI-Backend (ai-backend/, TODO 4.5.9) ─────────────────────────────────────── +# Echte Zugangsdaten/API-Keys, nie committen — config.example.php bleibt getrackt. +ai-backend/config.php docker/data/ # Umgebungsvariablen – .env.example ins Repo, .env nicht diff --git a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs new file mode 100644 index 0000000..992edac --- /dev/null +++ b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs @@ -0,0 +1,86 @@ +namespace LehrerApp.Core.AiPlanning; + +/// +/// Wire-Vertrag mit dem externen KI-Backend (siehe ai-backend/, TODO 4.5.9). Bewusst getrennt von +/// den internen Domänenmodellen in Core/Models: dieser Vertrag muss unabhängig von internen +/// Domänen-Refactors abwärtskompatibel zum deployten PHP-Backend bleiben. +/// +public class AiPlanningRequest +{ + public string Instruction { get; set; } = ""; + public AiUnitContext Unit { get; set; } = new(); +} + +public class AiUnitContext +{ + public Guid Id { get; set; } + public string Title { get; set; } = ""; + public DateOnly? StartDate { get; set; } + public DateOnly? EndDate { get; set; } + public List Competencies { get; set; } = []; + public string? Notes { get; set; } + + // Nur Kontext für die KI, wird nicht zurückerwartet. + public string SubjectName { get; set; } = ""; + public int GradeLevel { get; set; } + public string GroupName { get; set; } = ""; + public List CompetencyCatalog { get; set; } = []; + public List AlternativePathCatalog { get; set; } = []; + + public List Lessons { get; set; } = []; +} + +public class AiCompetencyDomain +{ + public string Name { get; set; } = ""; + public List Items { get; set; } = []; +} + +public class AiCompetencyItem +{ + public string Code { get; set; } = ""; + public string Description { get; set; } = ""; +} + +public class AiAlternativePath +{ + public Guid Id { get; set; } + public string Name { get; set; } = ""; +} + +/// +/// ist der einzige Unterscheidungsmechanismus neu/geändert: gesetzt und einer +/// tatsächlich im Request gesendeten Lesson zugehörig = Änderung; null oder eine unbekannte Id = +/// neue Lesson. Eine unbekannte Id wird beim Import NIE als Update einer bestehenden Lesson +/// interpretiert (siehe AiPlanningService.ApplyResponse) — sonst könnte eine halluzinierte Id im +/// schlimmsten Fall eine fremde Lesson überschreiben. +/// +public class AiLesson +{ + public Guid? Id { get; set; } + public DateOnly? Date { get; set; } + public int? LessonNumber { get; set; } + public string Topic { get; set; } = ""; + public TimeOnly? StartTime { get; set; } + public List Phases { get; set; } = []; + public string? Homework { get; set; } + public string? Reflection { get; set; } +} + +public class AiPhaseStep +{ + public string Name { get; set; } = ""; + public int DurationMinutes { get; set; } + public string Activity { get; set; } = ""; + public string Material { get; set; } = ""; + public string Shorthand { get; set; } = ""; + // Name statt Guid: LLMs erfinden/verändern Guids unzuverlässig, ein Name ist beim Import + // gegen den Katalog abgleichbar (kein Treffer -> null = Hauptweg, kein Fehler). + public string? AlternativePathName { get; set; } +} + +public class AiPlanningResponse +{ + public List Lessons { get; set; } = []; + public string? Summary { get; set; } +} diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index 7cf5d9a..fbca8ed 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -184,6 +184,7 @@ public interface ICompetencyDomainRepository void Save(CompetencyDomain domain); void Delete(Guid id); void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel); + void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List domains); } public interface IShorthandCodeRepository { diff --git a/LehrerApp.Core/Services/CompetencyCatalogImportService.cs b/LehrerApp.Core/Services/CompetencyCatalogImportService.cs new file mode 100644 index 0000000..ef55087 --- /dev/null +++ b/LehrerApp.Core/Services/CompetencyCatalogImportService.cs @@ -0,0 +1,279 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Services; + +public enum CompetencyCatalogImportMode { Merge, Replace } + +public sealed record CompetencyCatalogImportConflict( + string Id, string Location, string ExistingValue, string ImportedValue); + +public sealed class CompetencyCatalogImportPreview +{ + public required Guid SubjectId { get; init; } + public required string SubjectName { get; init; } + public required int GradeLevel { get; init; } + public required List ImportedDomains { get; init; } + public required string ExistingFingerprint { get; init; } + public List Warnings { get; init; } = []; + public List Conflicts { get; init; } = []; + public int NewDomains { get; init; } + public int NewCompetencies { get; init; } + public int UnchangedCompetencies { get; init; } +} + +/// Analysiert Kompetenzkataloge vollständig, bevor bestehende Daten verändert werden. +public sealed class CompetencyCatalogImportService(ICompetencyDomainRepository repository) +{ + public CompetencyCatalogImportPreview Analyze( + string json, Guid subjectId, string subjectName, int gradeLevel) + { + CatalogDto dto; + try + { + dto = JsonSerializer.Deserialize(json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? throw new InvalidDataException("Die JSON-Datei ist leer."); + } + catch (JsonException ex) + { + throw new InvalidDataException($"Ungültiges JSON: {ex.Message}", ex); + } + + if (dto.Domains is null || dto.Domains.Count == 0) + throw new InvalidDataException("Der Import enthält keine Kompetenzbereiche."); + + var imported = new List(); + var domainKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + var competencyCodes = new HashSet(StringComparer.OrdinalIgnoreCase); + for (var domainIndex = 0; domainIndex < dto.Domains.Count; domainIndex++) + { + var sourceDomain = dto.Domains[domainIndex]; + var name = sourceDomain.Name?.Trim() ?? ""; + var code = sourceDomain.Code?.Trim() ?? ""; + if (name.Length == 0) + throw new InvalidDataException($"Kompetenzbereich {domainIndex + 1} besitzt keinen Namen."); + var domainKey = DomainKey(code, name); + if (!domainKeys.Add(domainKey)) + throw new InvalidDataException($"Der Kompetenzbereich „{name}“ kommt mehrfach vor."); + + var items = new List(); + var localKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (sourceItem, itemIndex) in (sourceDomain.Competencies ?? []).Select((item, index) => (item, index))) + { + var itemCode = sourceItem.Code?.Trim() ?? ""; + var description = sourceItem.Description?.Trim() ?? ""; + if (description.Length == 0) + throw new InvalidDataException( + $"Kompetenz {itemIndex + 1} im Bereich „{name}“ besitzt keine Beschreibung."); + var itemKey = ItemKey(itemCode, description); + if (!localKeys.Add(itemKey)) + throw new InvalidDataException( + $"Die Kompetenz „{(itemCode.Length > 0 ? itemCode : description)}“ kommt im Bereich „{name}“ mehrfach vor."); + if (itemCode.Length > 0 && !competencyCodes.Add(itemCode)) + throw new InvalidDataException($"Der Kompetenzcode „{itemCode}“ kommt in mehreren Bereichen vor."); + items.Add(new CompetencyItem + { + Code = itemCode, + Description = description, + SortOrder = itemIndex, + }); + } + + imported.Add(new CompetencyDomain + { + SubjectId = subjectId, + GradeLevel = gradeLevel, + Name = name, + Code = code, + SortOrder = domainIndex, + Items = items, + }); + } + + var warnings = new List(); + if (!string.IsNullOrWhiteSpace(dto.Subject) + && !dto.Subject.Trim().Equals(subjectName, StringComparison.OrdinalIgnoreCase)) + warnings.Add($"Die Datei nennt das Fach „{dto.Subject.Trim()}“, importiert wird in „{subjectName}“."); + if (dto.GradeLevel is > 0 and <= 13 && dto.GradeLevel != gradeLevel) + warnings.Add($"Die Datei nennt Klassenstufe {dto.GradeLevel}, ausgewählt ist Klassenstufe {gradeLevel}."); + + var existing = repository.GetBySubjectAndGrade(subjectId, gradeLevel); + var conflicts = new List(); + var newDomains = 0; + var newItems = 0; + var unchanged = 0; + var existingItemsByCode = existing + .SelectMany(domain => domain.Items.Select(item => (Domain: domain, Item: item))) + .Where(x => !string.IsNullOrWhiteSpace(x.Item.Code)) + .GroupBy(x => x.Item.Code, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + foreach (var importedDomain in imported) + { + var domainKey = DomainKey(importedDomain.Code, importedDomain.Name); + var existingDomain = existing.FirstOrDefault(d => DomainKey(d.Code, d.Name) == domainKey); + if (existingDomain is null) newDomains++; + else if (existingDomain.Name != importedDomain.Name) + conflicts.Add(new($"domain:{domainKey}", $"Bereich {importedDomain.Code}", + existingDomain.Name, importedDomain.Name)); + + foreach (var importedItem in importedDomain.Items) + { + if (importedItem.Code.Length > 0 + && existingItemsByCode.TryGetValue(importedItem.Code, out var found)) + { + if (found.Item.Description == importedItem.Description + && existingDomain?.Id == found.Domain.Id) unchanged++; + else + conflicts.Add(new($"item:{importedItem.Code.ToUpperInvariant()}", + $"Kompetenz {importedItem.Code}", + $"{found.Item.Description} [{found.Domain.Name}]", + $"{importedItem.Description} [{importedDomain.Name}]")); + continue; + } + + var existingWithoutCode = existingDomain?.Items.FirstOrDefault(i => + i.Code.Length == 0 && i.Description.Equals(importedItem.Description, StringComparison.OrdinalIgnoreCase)); + if (existingWithoutCode is not null) unchanged++; + else newItems++; + } + } + + return new CompetencyCatalogImportPreview + { + SubjectId = subjectId, + SubjectName = subjectName, + GradeLevel = gradeLevel, + ImportedDomains = imported, + ExistingFingerprint = Fingerprint(existing), + Warnings = warnings, + Conflicts = conflicts.DistinctBy(c => c.Id).ToList(), + NewDomains = newDomains, + NewCompetencies = newItems, + UnchangedCompetencies = unchanged, + }; + } + + public void Apply(CompetencyCatalogImportPreview preview, CompetencyCatalogImportMode mode, + IReadOnlySet? useImportedConflicts = null) + { + var current = repository.GetBySubjectAndGrade(preview.SubjectId, preview.GradeLevel); + if (Fingerprint(current) != preview.ExistingFingerprint) + throw new InvalidOperationException( + "Der Kompetenzkatalog wurde seit der Vorschau verändert. Bitte öffne den Import erneut."); + + List result = mode == CompetencyCatalogImportMode.Replace + ? preview.ImportedDomains.Select((d, index) => CloneAsNew(d, preview.SubjectId, preview.GradeLevel, index)).ToList() + : Merge(current, preview, useImportedConflicts ?? new HashSet()); + + repository.ReplaceForSubjectAndGrade(preview.SubjectId, preview.GradeLevel, result); + } + + private static List Merge(List existing, + CompetencyCatalogImportPreview preview, IReadOnlySet useImported) + { + var result = existing.Select(ClonePreservingIds).OrderBy(d => d.SortOrder).ToList(); + foreach (var importedDomain in preview.ImportedDomains) + { + var domainKey = DomainKey(importedDomain.Code, importedDomain.Name); + var target = result.FirstOrDefault(d => DomainKey(d.Code, d.Name) == domainKey); + if (target is null) + { + target = CloneAsNew(importedDomain, preview.SubjectId, preview.GradeLevel, result.Count); + target.Items.Clear(); + result.Add(target); + } + else if (useImported.Contains($"domain:{domainKey}")) target.Name = importedDomain.Name; + + foreach (var importedItem in importedDomain.Items) + { + if (importedItem.Code.Length == 0) + { + if (!target.Items.Any(i => i.Code.Length == 0 + && i.Description.Equals(importedItem.Description, StringComparison.OrdinalIgnoreCase))) + target.Items.Add(CloneItemAsNew(importedItem, target.Items.Count)); + continue; + } + + var located = result.SelectMany(d => d.Items.Select(i => (Domain: d, Item: i))) + .FirstOrDefault(x => x.Item.Code.Equals(importedItem.Code, StringComparison.OrdinalIgnoreCase)); + if (located.Item is null) + { + target.Items.Add(CloneItemAsNew(importedItem, target.Items.Count)); + continue; + } + var conflictId = $"item:{importedItem.Code.ToUpperInvariant()}"; + if (!useImported.Contains(conflictId)) continue; + located.Domain.Items.Remove(located.Item); + located.Item.Description = importedItem.Description; + located.Item.Code = importedItem.Code; + located.Item.SortOrder = target.Items.Count; + target.Items.Add(located.Item); + NormalizeItemOrder(located.Domain); + } + } + for (var i = 0; i < result.Count; i++) result[i].SortOrder = i; + return result; + } + + private static CompetencyDomain ClonePreservingIds(CompetencyDomain source) => new() + { + Id = source.Id, SubjectId = source.SubjectId, GradeLevel = source.GradeLevel, + Name = source.Name, Code = source.Code, SortOrder = source.SortOrder, + Items = source.Items.OrderBy(i => i.SortOrder).Select(i => new CompetencyItem + { + Id = i.Id, Code = i.Code, Description = i.Description, SortOrder = i.SortOrder, + }).ToList(), + }; + + private static CompetencyDomain CloneAsNew(CompetencyDomain source, Guid subjectId, int gradeLevel, int sortOrder) => new() + { + SubjectId = subjectId, GradeLevel = gradeLevel, Name = source.Name, Code = source.Code, + SortOrder = sortOrder, + Items = source.Items.OrderBy(i => i.SortOrder).Select((i, index) => CloneItemAsNew(i, index)).ToList(), + }; + + private static CompetencyItem CloneItemAsNew(CompetencyItem source, int sortOrder) => new() + { + Code = source.Code, Description = source.Description, SortOrder = sortOrder, + }; + + private static void NormalizeItemOrder(CompetencyDomain domain) + { + for (var i = 0; i < domain.Items.Count; i++) domain.Items[i].SortOrder = i; + } + + private static string DomainKey(string? code, string? name) => + !string.IsNullOrWhiteSpace(code) ? $"code:{code.Trim().ToLowerInvariant()}" : $"name:{name?.Trim().ToLowerInvariant()}"; + + private static string ItemKey(string? code, string description) => + !string.IsNullOrWhiteSpace(code) ? $"code:{code.Trim().ToLowerInvariant()}" : $"description:{description.ToLowerInvariant()}"; + + private static string Fingerprint(IEnumerable domains) => string.Join("|", + domains.OrderBy(d => d.SortOrder).ThenBy(d => d.Id).Select(d => + $"{d.Id:N}:{d.UpdatedAt.Ticks}:{d.Name}:{d.Code}:" + + string.Join(";", d.Items.OrderBy(i => i.SortOrder).Select(i => $"{i.Id:N}:{i.Code}:{i.Description}:{i.SortOrder}")))); + + private sealed class CatalogDto + { + [JsonPropertyName("subject")] public string? Subject { get; set; } + [JsonPropertyName("gradeLevel")] public int GradeLevel { get; set; } + [JsonPropertyName("domains")] public List? Domains { get; set; } + } + + private sealed class DomainDto + { + [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("code")] public string? Code { get; set; } + [JsonPropertyName("competencies")] public List? Competencies { get; set; } + } + + private sealed class ItemDto + { + [JsonPropertyName("code")] public string? Code { get; set; } + [JsonPropertyName("description")] public string? Description { get; set; } + } +} diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 326c050..b0889f5 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -603,4 +603,21 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep .ToList()) db.CompetencyDomains.Delete(d.Id); } + public void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List domains) + { + db.ExecuteInTransaction(() => + { + foreach (var domain in db.CompetencyDomains + .Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel).ToList()) + db.CompetencyDomains.Delete(domain.Id); + var now = DateTime.UtcNow; + foreach (var domain in domains) + { + domain.SubjectId = subjectId; + domain.GradeLevel = gradeLevel; + domain.UpdatedAt = now; + db.CompetencyDomains.Upsert(domain); + } + }); + } } diff --git a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs new file mode 100644 index 0000000..dd51da5 --- /dev/null +++ b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs @@ -0,0 +1,197 @@ +using LehrerApp.Core.AiPlanning; +using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Tests für die reinen (nur Repository-Lesezugriff, kein Netzwerk) Teile von AiPlanningService: +/// BuildContext (Export) und ApplyResponse (Import). Login/GetBalanceAsync/RequestPlanAsync +/// brauchen einen echten HTTP-Endpunkt und sind nicht Teil dieser Tests (siehe Planungsdokument, +/// Abschnitt "Nicht ohne echtes Deployment ... verifizierbar"). +public sealed class AiPlanningServiceTests +{ + private static AiPlanningService Build(FakeLessons lessons, FakeGroups groups, FakeSubjects subjects, + FakeCompetencyDomains competencyDomains, FakeAlternativeLessonPaths altPaths) => + new(new HttpClient(), lessons, groups, subjects, competencyDomains, altPaths); + + [Fact] + public void BuildContext_FuelltGruppenUndFachKontext() + { + var subject = new Subject { Name = "Chemie" }; + var group = new LearningGroup { Name = "9c", SubjectId = subject.Id, GradeLevel = 9 }; + var unit = new Unit { GroupId = group.Id, Title = "Redoxreaktionen" }; + + var service = Build(new FakeLessons(), new FakeGroups([group]), new FakeSubjects([subject]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + + var context = service.BuildContext(unit, "Testanweisung"); + + Assert.Equal(unit.Id, context.Id); + Assert.Equal("Redoxreaktionen", context.Title); + Assert.Equal("Chemie", context.SubjectName); + Assert.Equal(9, context.GradeLevel); + Assert.Equal("9c", context.GroupName); + } + + [Fact] + public void BuildContext_FiltertKompetenzkatalogNachFachUndStufe() + { + var subject = new Subject { Name = "Chemie" }; + var group = new LearningGroup { SubjectId = subject.Id, GradeLevel = 9 }; + var unit = new Unit { GroupId = group.Id, Title = "T" }; + + var domains = new FakeCompetencyDomains(); + domains.Add(new CompetencyDomain + { + SubjectId = subject.Id, GradeLevel = 9, Name = "Chemische Reaktionen", + Items = [new CompetencyItem { Code = "C1", Description = "Redoxreaktionen erklären" }], + }); + + var service = Build(new FakeLessons(), new FakeGroups([group]), new FakeSubjects([subject]), + domains, new FakeAlternativeLessonPaths([])); + + var context = service.BuildContext(unit, ""); + + var catalogDomain = Assert.Single(context.CompetencyCatalog); + Assert.Equal("Chemische Reaktionen", catalogDomain.Name); + var item = Assert.Single(catalogDomain.Items); + Assert.Equal("C1", item.Code); + Assert.Equal("Redoxreaktionen erklären", item.Description); + } + + [Fact] + public void BuildContext_EnthaeltVorhandeneLessonsMitEchtenIds() + { + var group = new LearningGroup(); + var unit = new Unit { GroupId = group.Id, Title = "T" }; + var lesson = new Lesson { UnitId = unit.Id, GroupId = group.Id, Topic = "Erste Stunde" }; + var lessons = new FakeLessons(); + lessons.Add(lesson); + + var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + + var context = service.BuildContext(unit, ""); + + var aiLesson = Assert.Single(context.Lessons); + Assert.Equal(lesson.Id, aiLesson.Id); + Assert.Equal("Erste Stunde", aiLesson.Topic); + } + + [Fact] + public void BuildContext_LoestAlternativePathNameAuf() + { + var group = new LearningGroup(); + var unit = new Unit { GroupId = group.Id, Title = "T" }; + var path = new AlternativeLessonPath { Name = "Vertiefung" }; + var lesson = new Lesson + { + UnitId = unit.Id, GroupId = group.Id, Topic = "Stunde", + Phases = [new LessonPhaseStep { Name = "Erarbeitung", AlternativePathId = path.Id }], + }; + var lessons = new FakeLessons(); + lessons.Add(lesson); + + var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([path])); + + var context = service.BuildContext(unit, ""); + + var phase = Assert.Single(Assert.Single(context.Lessons).Phases); + Assert.Equal("Vertiefung", phase.AlternativePathName); + Assert.Contains(context.AlternativePathCatalog, p => p.Name == "Vertiefung" && p.Id == path.Id); + } + + [Fact] + public void ApplyResponse_BekannteId_WirdAlsUpdateBehandelt() + { + var group = new LearningGroup(); + var unit = new Unit { GroupId = group.Id, Title = "T" }; + var existing = new Lesson { UnitId = unit.Id, GroupId = group.Id, Topic = "Alt" }; + var lessons = new FakeLessons(); + lessons.Add(existing); + + var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + + var accepted = new List { new() { Id = existing.Id, Topic = "Neu formuliert" } }; + var result = service.ApplyResponse(unit, accepted); + + var lesson = Assert.Single(result); + Assert.Equal(existing.Id, lesson.Id); + Assert.Equal("Neu formuliert", lesson.Topic); + Assert.Equal(unit.Id, lesson.UnitId); + Assert.Equal(group.Id, lesson.GroupId); + } + + [Fact] + public void ApplyResponse_NullId_WirdAlsNeueLessonBehandelt() + { + var group = new LearningGroup(); + var unit = new Unit { GroupId = group.Id, Title = "T" }; + + var service = Build(new FakeLessons(), new FakeGroups([group]), new FakeSubjects([]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + + var accepted = new List { new() { Id = null, Topic = "Ganz neu" } }; + var result = service.ApplyResponse(unit, accepted); + + var lesson = Assert.Single(result); + Assert.NotEqual(Guid.Empty, lesson.Id); + Assert.Equal("Ganz neu", lesson.Topic); + } + + /// Zentraler Sicherheitstest (siehe Planungsdokument): eine von der KI zurückgegebene Id, die + /// zu keiner tatsächlich zur Einheit gehörenden Lesson passt, darf NIE als Update interpretiert + /// werden — sonst könnte eine halluzinierte/fremde Id im schlimmsten Fall eine fremde Lesson + /// überschreiben. Sie muss stattdessen wie eine neue Lesson behandelt werden (frische Id). + [Fact] + public void ApplyResponse_UnbekannteFremdeId_WirdNieAlsUpdateUebernommen() + { + var group = new LearningGroup(); + var unit = new Unit { GroupId = group.Id, Title = "T" }; + var existing = new Lesson { UnitId = unit.Id, GroupId = group.Id, Topic = "Bestehende Stunde" }; + var lessons = new FakeLessons(); + lessons.Add(existing); + + var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + + var foreignId = Guid.NewGuid(); // gehört zu keiner Lesson dieser Einheit + var accepted = new List { new() { Id = foreignId, Topic = "Verdächtig" } }; + var result = service.ApplyResponse(unit, accepted); + + var lesson = Assert.Single(result); + Assert.NotEqual(foreignId, lesson.Id); + Assert.NotEqual(existing.Id, lesson.Id); + } + + [Fact] + public void ApplyResponse_LoestAlternativePathNameZurueckZuId_KeinTrefferBleibtNull() + { + var group = new LearningGroup(); + var unit = new Unit { GroupId = group.Id, Title = "T" }; + var path = new AlternativeLessonPath { Name = "Förderung" }; + + var service = Build(new FakeLessons(), new FakeGroups([group]), new FakeSubjects([]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([path])); + + var accepted = new List + { + new() + { + Topic = "T", Phases = + [ + new AiPhaseStep { Name = "A", AlternativePathName = "Förderung" }, + new AiPhaseStep { Name = "B", AlternativePathName = "Unbekannter Pfad" }, + ], + }, + }; + var result = service.ApplyResponse(unit, accepted); + + var phases = Assert.Single(result).Phases; + Assert.Equal(path.Id, phases[0].AlternativePathId); + Assert.Null(phases[1].AlternativePathId); + } +} diff --git a/LehrerApp.Desktop.Tests/AiSettingsServiceTests.cs b/LehrerApp.Desktop.Tests/AiSettingsServiceTests.cs new file mode 100644 index 0000000..9cc50a9 --- /dev/null +++ b/LehrerApp.Desktop.Tests/AiSettingsServiceTests.cs @@ -0,0 +1,73 @@ +using LehrerApp.Desktop.Services; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class AiSettingsServiceTests +{ + private static string BuildTempPath() + { + var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-aisettingssvc-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + + [Fact] + public void SetEnabled_PersistiertUeberNeueInstanz() + { + var path = BuildTempPath(); + new AiSettingsService(path).SetEnabled(true); + + var reloaded = new AiSettingsService(path); + + Assert.True(reloaded.Enabled); + } + + [Fact] + public void SetCredentialsAndToken_TokenIstVerschluesseltAbrufbar() + { + var service = new AiSettingsService(BuildTempPath()); + + service.SetCredentialsAndToken("sebastian", "geheimes-token-123"); + + Assert.True(service.IsLoggedIn); + Assert.Equal("sebastian", service.Username); + Assert.Equal("geheimes-token-123", service.GetToken()); + } + + [Fact] + public void Token_UeberlebtNeueInstanzMitDemselbenPfad() + { + var path = BuildTempPath(); + new AiSettingsService(path).SetCredentialsAndToken("sebastian", "token-abc"); + + var reloaded = new AiSettingsService(path); + + Assert.True(reloaded.IsLoggedIn); + Assert.Equal("token-abc", reloaded.GetToken()); + } + + [Fact] + public void TokenDateiEnthaeltNichtDenKlartext() + { + var path = BuildTempPath(); + var service = new AiSettingsService(path); + service.SetCredentialsAndToken("sebastian", "geheimes-token-123"); + + var raw = File.ReadAllText(Path.Combine(path, "ai-settings.json")); + + Assert.DoesNotContain("geheimes-token-123", raw); + } + + [Fact] + public void Logout_EntferntToken() + { + var service = new AiSettingsService(BuildTempPath()); + service.SetCredentialsAndToken("sebastian", "token-abc"); + + service.Logout(); + + Assert.False(service.IsLoggedIn); + Assert.Null(service.GetToken()); + } +} diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index bda89e8..42c89f9 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -1,11 +1,33 @@ using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; namespace LehrerApp.Desktop.Tests; // Einfache In-Memory-Fakes der Repository-Schnittstellen, damit ViewModel-Tests ohne echte // LiteDB-Anbindung laufen. Bewusst schlank gehalten: nur was die getesteten ViewModels brauchen. +public static class TestSupport +{ + /// Für Tests, die eine echte AiSettingsService-Instanz brauchen (dateibasiert wie + /// PeriodScheduleService & Co.) — eigenes Temp-Verzeichnis je Aufruf, damit Tests sich nicht + /// gegenseitig über dieselbe ai-settings.json/ai-token.key stören. + public static AiSettingsService BuildAiSettingsService() + { + var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-aisettings-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + return new AiSettingsService(tempPath); + } + + /// Für Tests, die nur einen validen AiPlanningService zum Durchreichen brauchen (z.B. weil + /// SettingsViewModel/PlanningTabViewModel ihn im Konstruktor verlangen), nicht seine eigentliche + /// Funktionalität testen — leere Fakes genügen, es wird kein echter HTTP-Aufruf ausgelöst, + /// solange AiSettingsService.IsLoggedIn false ist. + public static AiPlanningService BuildAiPlanningService() => new( + new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); +} + public class FakeStudents(List all) : IStudentRepository { private readonly Dictionary _references = []; @@ -209,11 +231,20 @@ public class FakeSubjects(List all) : ISubjectRepository public class FakeCompetencyDomains : ICompetencyDomainRepository { - public List GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => []; - public CompetencyDomain? GetById(Guid id) => null; - public void Save(CompetencyDomain domain) { } - public void Delete(Guid id) { } - public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel) { } + private readonly List _all = []; + public void Add(CompetencyDomain d) => _all.Add(d); + public List GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => + _all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel).ToList(); + public CompetencyDomain? GetById(Guid id) => _all.FirstOrDefault(d => d.Id == id); + public void Save(CompetencyDomain domain) { _all.RemoveAll(d => d.Id == domain.Id); _all.Add(domain); } + public void Delete(Guid id) => _all.RemoveAll(d => d.Id == id); + public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel) => + _all.RemoveAll(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel); + public void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List domains) + { + DeleteBySubjectAndGrade(subjectId, gradeLevel); + _all.AddRange(domains); + } } public class FakeShorthandCodes(List all) : IShorthandCodeRepository diff --git a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs index 8f40b81..beeec2f 100644 --- a/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/GroupDetailViewModelTests.cs @@ -21,7 +21,8 @@ public sealed class GroupDetailViewModelTests new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(), students, memberships, groups, new FakeCompetencyDomains()), new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()), - new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects, new FakeCompetencyDomains())); + new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects, + new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService())); vm.LoadGroup(group.Id); vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id); diff --git a/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs b/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs index 8e8be5b..92ac451 100644 --- a/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/PlanningTabViewModelTests.cs @@ -1,4 +1,5 @@ using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.Groups; using Xunit; @@ -18,7 +19,8 @@ public class PlanningTabViewModelTests var units = new FakeUnits(); var lessons = new FakeLessons(); - var vm = new PlanningTabViewModel(units, lessons, groups, subjects, competencyDomains); + var vm = new PlanningTabViewModel(units, lessons, groups, subjects, competencyDomains, + TestSupport.BuildAiSettingsService()); vm.Initialize(groupId); return (vm, units, lessons, groupId); diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs index 3d9aba5..9933d0d 100644 --- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -25,7 +25,7 @@ public sealed class SettingsViewModelTests new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), - new LetterTemplateService(tempPath)); + new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService()); } [Fact] @@ -101,7 +101,7 @@ public sealed class SettingsViewModelTests new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), - new FakeSupervisionDuties(), new LetterTemplateService(tempPath)); + new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService()); vm.SelectedStateName = "Bayern"; @@ -123,7 +123,7 @@ public sealed class SettingsViewModelTests new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, - new FakeSupervisionDuties(), new LetterTemplateService(tempPath)); + new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService()); vm.PeriodTimes[0].StartText = "08:00"; vm.PeriodTimes[0].EndText = "08:45"; @@ -149,7 +149,7 @@ public sealed class SettingsViewModelTests new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, - new FakeSupervisionDuties(), new LetterTemplateService(tempPath)); + new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService()); vm.PeriodTimes[0].StartText = "08:45"; vm.PeriodTimes[0].EndText = "08:00"; diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 9a69b3a..66dc4e0 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -27,6 +27,13 @@ public static class AppBootstrapper public static string DbPath { get; private set; } = ""; public static string AppDataPath { get; private set; } = ""; + /// + /// Feste URL des KI-Backends (ai-backend/, TODO 4.5.9) — bewusst nicht in den Einstellungen + /// editierbar, siehe Planungsdokument. Vor dem ersten produktiven Einsatz durch die tatsächlich + /// deployte Domain ersetzen. + /// + public const string AiBackendUrl = "https://REPLACE_ME.example.com/"; + /// /// Vor gesetzt, wenn die Datenbank passwortgeschützt ist /// (siehe App.axaml.cs: Passwort-Abfrage vor dem Öffnen der Datenbank). @@ -147,6 +154,11 @@ public static class AppBootstrapper services.AddSingleton(_ => new WorkloadSettingsService(appData)); services.AddSingleton(_ => new LetterTemplateService(appData)); + // ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ────── + services.AddSingleton(_ => new AiSettingsService(appData)); + services.AddSingleton(_ => new HttpClient { BaseAddress = new Uri(AiBackendUrl) }); + services.AddSingleton(); + // ── Sync (optional – nur wenn Server konfiguriert) ──────────────────── services.AddSingleton(_ => new EventQueue(queuePath)); services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService())); diff --git a/LehrerApp.Desktop/Services/AiPlanningService.cs b/LehrerApp.Desktop/Services/AiPlanningService.cs new file mode 100644 index 0000000..2009d9e --- /dev/null +++ b/LehrerApp.Desktop/Services/AiPlanningService.cs @@ -0,0 +1,213 @@ +using LehrerApp.Core.AiPlanning; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; + +namespace LehrerApp.Desktop.Services; + +/// Fehler beim Aufruf des KI-Backends, Message ist bereits deutsch und nutzergerichtet. +public class AiBackendException(string userMessage) : Exception(userMessage); + +/// +/// 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, + }; + + 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. + public AiUnitContext BuildContext(Unit unit, string instruction) + { + 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 = l.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(), + }) + .ToList(); + + 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, + }; + } + + public async Task RequestPlanAsync(Unit unit, string instruction, string token) + { + var request = new AiPlanningRequest { Instruction = instruction, Unit = BuildContext(unit, instruction) }; + + 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). + /// + public List ApplyResponse(Unit unit, List acceptedLessons) + { + var existingIds = lessons.GetByUnit(unit.Id).Select(l => l.Id).ToHashSet(); + var pathIdsByName = altPaths.GetAll().ToDictionary(p => p.Name, p => p.Id); + + var result = new List(); + foreach (var ai in acceptedLessons) + { + var isUpdate = ai.Id is { } id && existingIds.Contains(id); + 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, + 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; } } +} diff --git a/LehrerApp.Desktop/Services/AiSettingsService.cs b/LehrerApp.Desktop/Services/AiSettingsService.cs new file mode 100644 index 0000000..23c5333 --- /dev/null +++ b/LehrerApp.Desktop/Services/AiSettingsService.cs @@ -0,0 +1,86 @@ +using LehrerApp.Sync.Crypto; +using System.Text.Json; + +namespace LehrerApp.Desktop.Services; + +internal class AiSettingsConfig +{ + public bool Enabled { get; set; } + public string Username { get; set; } = ""; + public string? EncryptedToken { get; set; } +} + +/// +/// Einstellungen für die KI-gestützte Planungsunterstützung (TODO 4.5.9). Liegt in +/// LehrerApp.Desktop statt LehrerApp.Core, weil die Token-Verschlüsselung +/// aus LehrerApp.Sync nutzt — Core bleibt bewusst frei von Abhängigkeiten außerhalb von .NET +/// selbst (siehe CLAUDE.md), Sync hängt von Core ab, nicht umgekehrt. +/// +/// Das Passwort wird nie persistiert, nur das nach erfolgreichem Login vom Backend ausgestellte +/// Bearer-Token — und auch das nur verschlüsselt (AES-256-GCM über SyncCrypto, gleicher +/// Mechanismus wie beim Sync-Schlüssel). Der Schlüssel selbst liegt dateirechte-geschützt +/// (chmod 600 unter Unix) neben der Einstellungsdatei — kein Betriebssystem-Schlüsselbund, aber +/// deutlich besser als die bisherige Klartext-Ablage der Sync-Server-URL. +/// +public class AiSettingsService +{ + private readonly string _configPath; + private readonly string _keyPath; + private readonly byte[] _tokenKey; + private AiSettingsConfig _config; + + public bool Enabled => _config.Enabled; + public string Username => _config.Username; + public bool IsLoggedIn => _config.EncryptedToken is not null; + + public AiSettingsService(string appDataPath) + { + _configPath = Path.Combine(appDataPath, "ai-settings.json"); + _keyPath = Path.Combine(appDataPath, "ai-token.key"); + _tokenKey = SyncCrypto.LoadKey(_keyPath) ?? GenerateAndSaveKey(); + _config = Load(); + } + + public void SetEnabled(bool enabled) + { + _config.Enabled = enabled; + Save(); + } + + public void SetCredentialsAndToken(string username, string token) + { + _config.Username = username; + _config.EncryptedToken = SyncCrypto.EncryptObject(token, _tokenKey); + Save(); + } + + public string? GetToken() => + _config.EncryptedToken is null ? null : SyncCrypto.DecryptObject(_config.EncryptedToken, _tokenKey); + + public void Logout() + { + _config.EncryptedToken = null; + Save(); + } + + private byte[] GenerateAndSaveKey() + { + var key = SyncCrypto.GenerateKey(); + SyncCrypto.SaveKey(key, _keyPath); + return key; + } + + private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config)); + + private AiSettingsConfig Load() + { + try + { + if (File.Exists(_configPath)) + return JsonSerializer.Deserialize(File.ReadAllText(_configPath)) + ?? new AiSettingsConfig(); + } + catch { /* beschädigte Konfiguration -> Standardwert */ } + return new AiSettingsConfig(); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index 403eaf1..ed079f5 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -1,8 +1,10 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.AiPlanning; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; +using LehrerApp.Desktop.Services; using System.Collections.ObjectModel; using System.Globalization; @@ -29,6 +31,7 @@ public partial class PlanningTabViewModel : ObservableObject private readonly IGroupRepository _groups; private readonly ISubjectRepository _subjects; private readonly ICompetencyDomainRepository _competencyDomains; + private readonly AiSettingsService _aiSettings; private Guid _groupId; @@ -66,13 +69,15 @@ public partial class PlanningTabViewModel : ObservableObject public Func>? OnPickMoveTarget { get; set; } public Func? OnShowLesson { get; set; } public Func>? OnGenerateLessonSeries { get; set; } + public Func>? OnAiAssist { get; set; } public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons, IGroupRepository groups, ISubjectRepository subjects, - ICompetencyDomainRepository competencyDomains) + ICompetencyDomainRepository competencyDomains, AiSettingsService aiSettings) { _units = units; _lessons = lessons; _groups = groups; _subjects = subjects; _competencyDomains = competencyDomains; + _aiSettings = aiSettings; } public void Initialize(Guid groupId, bool isReadOnly = false) @@ -121,6 +126,7 @@ public partial class PlanningTabViewModel : ObservableObject CopyUnitCommand.NotifyCanExecuteChanged(); AddLessonCommand.NotifyCanExecuteChanged(); GenerateLessonSeriesCommand.NotifyCanExecuteChanged(); + AiAssistCommand.NotifyCanExecuteChanged(); } private void LoadLessons() @@ -144,6 +150,7 @@ public partial class PlanningTabViewModel : ObservableObject private bool HasSelectedUnit() => SelectedUnit is not null; private bool HasSelectedLesson() => SelectedLesson is not null; + private bool CanAiAssist() => SelectedUnit is not null && _aiSettings.Enabled; // ── Einheiten (4.1) ──────────────────────────────────────────────────────── @@ -253,6 +260,15 @@ public partial class PlanningTabViewModel : ObservableObject if (result is not null) LoadUnits(); } + /// KI-gestützte Planungsunterstützung (4.5.9) — die eigentliche Anfrage/Auswertung läuft im + /// Dialog (), hier wird nur nachgeladen. + [RelayCommand(CanExecute = nameof(CanAiAssist))] + private async Task AiAssist() + { + if (OnAiAssist is null || SelectedUnit is null) return; + if (await OnAiAssist(SelectedUnit.Model)) LoadUnits(); + } + [RelayCommand(CanExecute = nameof(HasSelectedLesson))] private async Task EditLesson() { @@ -1067,6 +1083,91 @@ public partial class GenerateLessonSeriesDialogViewModel : ObservableObject } } +// ── Dialog: KI-gestützte Planungsunterstützung (4.5.9) ─────────────────────── + +/// Eine von der KI vorgeschlagene Stunde in der Prüfliste des Dialogs — angehakt = wird beim +/// "Übernehmen" mit übertragen. unterscheidet neu/geändert (siehe +/// AiPlanningDtos.cs), steuert hier nur die Anzeige ("Neu"/"Geändert"). +public partial class AiLessonReviewItem : ObservableObject +{ + public AiLesson Source { get; } + public bool IsNew { get; } + public string DisplayLabel { get; } + + [ObservableProperty] private bool _accepted = true; + + public AiLessonReviewItem(AiLesson source, bool isNew) + { + Source = source; + IsNew = isNew; + var dateText = source.Date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "kein Datum"; + DisplayLabel = isNew ? $"Neu: {source.Topic} ({dateText})" : $"Geändert: {source.Topic} ({dateText})"; + } +} + +public partial class AiAssistDialogViewModel : ObservableObject +{ + private readonly AiPlanningService _aiPlanning; + private readonly AiSettingsService _aiSettings; + private readonly ILessonRepository _lessons; + private readonly Unit _unit; + + [ObservableProperty] private string _instruction = ""; + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private string _errorMessage = ""; + [ObservableProperty] private bool _hasResults; + [ObservableProperty] private string? _summary; + + public string UnitSummary { get; } + public ObservableCollection ReviewItems { get; } = []; + public bool Result { get; private set; } + + public AiAssistDialogViewModel(AiPlanningService aiPlanning, AiSettingsService aiSettings, + ILessonRepository lessons, Unit unit) + { + _aiPlanning = aiPlanning; _aiSettings = aiSettings; _lessons = lessons; _unit = unit; + var lessonCount = lessons.GetByUnit(unit.Id).Count; + UnitSummary = $"Einheit: {unit.Title} — {lessonCount} Stunde(n)"; + } + + [RelayCommand] + private async Task Send() + { + var token = _aiSettings.GetToken(); + if (token is null) + { + ErrorMessage = "Nicht angemeldet. Bitte in den Einstellungen bei der KI-Unterstützung anmelden."; + return; + } + + ErrorMessage = ""; IsBusy = true; + try + { + var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token); + var existingIds = _lessons.GetByUnit(_unit.Id).Select(l => l.Id).ToHashSet(); + + ReviewItems.Clear(); + foreach (var l in response.Lessons) + ReviewItems.Add(new AiLessonReviewItem(l, isNew: l.Id is not { } id || !existingIds.Contains(id))); + Summary = response.Summary; + HasResults = true; + } + catch (AiBackendException ex) { ErrorMessage = ex.Message; } + finally { IsBusy = false; } + } + + [RelayCommand] + private void Apply() + { + var accepted = ReviewItems.Where(i => i.Accepted).Select(i => i.Source).ToList(); + foreach (var lesson in _aiPlanning.ApplyResponse(_unit, accepted)) + _lessons.Save(lesson); + Result = true; + } + + [RelayCommand] private void Cancel() => Result = false; +} + // ── Dialog: Einheit als Vorlage in andere Gruppe kopieren (4.1.4) ──────────── public partial class CopyUnitDialogViewModel : ObservableObject diff --git a/LehrerApp.Desktop/ViewModels/Settings/CompetencyCatalogImportViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/CompetencyCatalogImportViewModel.cs new file mode 100644 index 0000000..319f1fd --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Settings/CompetencyCatalogImportViewModel.cs @@ -0,0 +1,95 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using LehrerApp.Core.Services; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels.Settings; + +public partial class CompetencyCatalogImportViewModel : ObservableObject +{ + private readonly SettingsViewModel _settings; + private readonly CompetencyCatalogImportPreview _preview; + + [ObservableProperty] private ImportModeOption _selectedMode; + [ObservableProperty] private bool _replaceConfirmed; + [ObservableProperty] private string _error = ""; + + public IReadOnlyList Modes { get; } + public ObservableCollection Conflicts { get; } = []; + public IReadOnlyList Warnings => _preview.Warnings; + public string Target => $"{_preview.SubjectName} · Klassenstufe {_preview.GradeLevel}"; + public string Summary => + $"{_preview.NewDomains} neue Bereiche · {_preview.NewCompetencies} neue Kompetenzen · " + + $"{_preview.UnchangedCompetencies} unverändert · {_preview.Conflicts.Count} Konflikte"; + public bool HasWarnings => Warnings.Count > 0; + public bool HasConflicts => Conflicts.Count > 0; + public bool IsMerge => SelectedMode.Mode == CompetencyCatalogImportMode.Merge; + public bool IsReplace => SelectedMode.Mode == CompetencyCatalogImportMode.Replace; + + public CompetencyCatalogImportViewModel( + SettingsViewModel settings, CompetencyCatalogImportPreview preview) + { + _settings = settings; + _preview = preview; + Modes = + [ + new(CompetencyCatalogImportMode.Merge, "Zusammenführen (empfohlen)"), + new(CompetencyCatalogImportMode.Replace, "Vorhandenen Katalog ersetzen"), + ]; + _selectedMode = Modes[0]; + foreach (var conflict in preview.Conflicts) + Conflicts.Add(new CompetencyImportConflictItem(conflict)); + } + + partial void OnSelectedModeChanged(ImportModeOption value) + { + Error = ""; + ReplaceConfirmed = false; + OnPropertyChanged(nameof(IsMerge)); + OnPropertyChanged(nameof(IsReplace)); + } + + public bool TryApply() + { + Error = ""; + if (IsReplace && !ReplaceConfirmed) + { + Error = "Bitte bestätige das vollständige Ersetzen des vorhandenen Katalogs."; + return false; + } + + try + { + var importedConflicts = Conflicts + .Where(x => x.UseImported) + .Select(x => x.Id) + .ToHashSet(StringComparer.Ordinal); + _settings.ApplyCatalogImport(_preview, SelectedMode.Mode, importedConflicts); + return true; + } + catch (InvalidOperationException ex) + { + Error = ex.Message; + return false; + } + } +} + +public sealed record ImportModeOption(CompetencyCatalogImportMode Mode, string Label); + +public partial class CompetencyImportConflictItem : ObservableObject +{ + [ObservableProperty] private bool _useImported; + + public string Id { get; } + public string Location { get; } + public string ExistingValue { get; } + public string ImportedValue { get; } + + public CompetencyImportConflictItem(CompetencyCatalogImportConflict conflict) + { + Id = conflict.Id; + Location = conflict.Location; + ExistingValue = conflict.ExistingValue; + ImportedValue = conflict.ImportedValue; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index d933807..fc7784d 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -4,6 +4,7 @@ using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Data; +using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.Planning; using System.Collections.ObjectModel; using System.Globalization; @@ -154,12 +155,24 @@ public partial class SettingsViewModel : ObservableObject public string[] WeekdayOptions { get; } = WeekdayDisplay.Options; public ObservableCollection SupervisionDuties { get; } = []; + // ── KI-Unterstützung (4.5.9) ────────────────────────────────────────────── + + [ObservableProperty] private bool _aiEnabled; + [ObservableProperty] private string _aiUsername = ""; + [ObservableProperty] private string _aiPassword = ""; + [ObservableProperty] private string _aiLoginError = ""; + [ObservableProperty] private bool _aiIsLoggedIn; + [ObservableProperty] private string _aiBalanceDisplay = ""; + // ── Konstruktor ─────────────────────────────────────────────────────────── private readonly ISchoolHolidayRepository _schoolHolidays; private readonly SchoolCalendarSettingsService _calendarSettings; private readonly PeriodScheduleService _periodSchedule; private readonly ISupervisionDutyRepository _supervisionDuties; + private readonly AiSettingsService _aiSettings; + private readonly AiPlanningService _aiPlanning; + private readonly CompetencyCatalogImportService _catalogImport; public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes, @@ -168,7 +181,8 @@ public partial class SettingsViewModel : ObservableObject IDocumentationRepository documentation, IStudentRepository students, IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, - ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates) + ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, + AiSettingsService aiSettings, AiPlanningService aiPlanning) { _subjects = subjects; _domainRepo = domainRepo; @@ -188,6 +202,9 @@ public partial class SettingsViewModel : ObservableObject _periodSchedule = periodSchedule; _supervisionDuties = supervisionDuties; _letterTemplates = letterTemplates; + _aiSettings = aiSettings; + _aiPlanning = aiPlanning; + _catalogImport = new CompetencyCatalogImportService(domainRepo); LoadSubjects(); LoadShorthandCodes(); LoadGradingKeyTemplates(); @@ -203,6 +220,7 @@ public partial class SettingsViewModel : ObservableObject LoadPeriodTimes(); LoadSupervisionDuties(); LoadLetterTemplates(); + LoadAiSettings(); } // ── Word-Briefvorlagen: Import und Validierung ────────────────────────── @@ -292,6 +310,58 @@ public partial class SettingsViewModel : ObservableObject SupervisionDuties.Remove(item); } + // ── KI-Unterstützung: Laden / Anmelden / Abmelden ──────────────────────── + + private void LoadAiSettings() + { + AiEnabled = _aiSettings.Enabled; + AiUsername = _aiSettings.Username; + AiIsLoggedIn = _aiSettings.IsLoggedIn; + if (AiIsLoggedIn) _ = RefreshAiBalance(); + } + + partial void OnAiEnabledChanged(bool value) => _aiSettings.SetEnabled(value); + + private async Task RefreshAiBalance() + { + var token = _aiSettings.GetToken(); + if (token is null) return; + try + { + var balance = await _aiPlanning.GetBalanceAsync(token); + AiBalanceDisplay = $"Guthaben: {balance:0.00} €"; + } + catch (AiBackendException ex) { AiBalanceDisplay = ex.Message; } + } + + [RelayCommand] + private async Task AiLogin() + { + AiLoginError = ""; + var valid = true; + if (string.IsNullOrWhiteSpace(AiUsername)) { AiLoginError = "Benutzername erforderlich."; valid = false; } + if (string.IsNullOrWhiteSpace(AiPassword)) { AiLoginError = "Passwort erforderlich."; valid = false; } + if (!valid) return; + + try + { + var token = await _aiPlanning.LoginAsync(AiUsername, AiPassword); + _aiSettings.SetCredentialsAndToken(AiUsername, token); + AiPassword = ""; + AiIsLoggedIn = true; + await RefreshAiBalance(); + } + catch (AiBackendException ex) { AiLoginError = ex.Message; } + } + + [RelayCommand] + private void AiLogout() + { + _aiSettings.Logout(); + AiIsLoggedIn = false; + AiBalanceDisplay = ""; + } + // ── Stundenraster: Laden / Speichern ───────────────────────────────────── private void LoadPeriodTimes() @@ -697,46 +767,36 @@ public partial class SettingsViewModel : ObservableObject // ── JSON Import / Export ────────────────────────────────────────────────── - public void ImportCatalog(string json) + public CompetencyCatalogImportPreview? PrepareCatalogImport(string json) { - if (CatalogSubject is null) { CatalogValidation = "Bitte zuerst ein Fach auswählen."; return; } + if (CatalogSubject is null) + { + CatalogValidation = "Bitte zuerst ein Fach auswählen."; + return null; + } try { - var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - var dto = JsonSerializer.Deserialize(json, opts); - if (dto?.Domains is null) { CatalogValidation = "Ungültiges JSON-Format."; return; } - - _domainRepo.DeleteBySubjectAndGrade(CatalogSubject.Id, CatalogGradeLevel); - - for (int i = 0; i < dto.Domains.Count; i++) - { - var d = dto.Domains[i]; - var domain = new CompetencyDomain - { - SubjectId = CatalogSubject.Id, - GradeLevel = CatalogGradeLevel, - Name = d.Name ?? "", - Code = d.Code ?? "", - SortOrder = i, - Items = (d.Competencies ?? []) - .Select((c, j) => new CompetencyItem - { - Code = c.Code ?? "", - Description = c.Description ?? "", - SortOrder = j, - }).ToList(), - }; - _domainRepo.Save(domain); - } CatalogValidation = ""; - LoadCatalog(); + return _catalogImport.Analyze( + json, CatalogSubject.Id, CatalogSubject.Name, CatalogGradeLevel); } - catch + catch (InvalidDataException ex) { - CatalogValidation = "Import fehlgeschlagen – bitte JSON-Format prüfen."; + CatalogValidation = ex.Message; + return null; } } + public void ApplyCatalogImport(CompetencyCatalogImportPreview preview, + CompetencyCatalogImportMode mode, IReadOnlySet useImportedConflicts) + { + _catalogImport.Apply(preview, mode, useImportedConflicts); + LoadCatalog(); + CatalogValidation = mode == CompetencyCatalogImportMode.Merge + ? "Kompetenzkatalog wurde sicher zusammengeführt." + : "Kompetenzkatalog wurde vollständig ersetzt."; + } + public string ExportCatalog() { var dto = new CatalogDto diff --git a/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml b/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml new file mode 100644 index 0000000..dfc69ab --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +