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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml.cs b/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml.cs
new file mode 100644
index 0000000..513515a
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml.cs
@@ -0,0 +1,31 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using LehrerApp.Desktop.ViewModels.Groups;
+
+namespace LehrerApp.Desktop.Views.Groups;
+
+public partial class AiAssistDialog : Window
+{
+ public AiAssistDialog() => InitializeComponent();
+
+ private async void OnSend(object? s, RoutedEventArgs e)
+ {
+ if (DataContext is AiAssistDialogViewModel vm && vm.SendCommand.CanExecute(null))
+ await vm.SendCommand.ExecuteAsync(null);
+ }
+
+ private void OnApply(object? s, RoutedEventArgs e)
+ {
+ if (DataContext is AiAssistDialogViewModel vm && vm.ApplyCommand.CanExecute(null))
+ {
+ vm.ApplyCommand.Execute(null);
+ Close(true);
+ }
+ }
+
+ private void OnCancel(object? s, RoutedEventArgs e)
+ {
+ if (DataContext is AiAssistDialogViewModel vm) vm.CancelCommand.Execute(null);
+ Close(false);
+ }
+}
diff --git a/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml b/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml
index f55e95c..ad827ff 100644
--- a/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml
+++ b/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml
@@ -15,6 +15,7 @@
+
diff --git a/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml.cs b/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml.cs
index 585c6e9..8ac0786 100644
--- a/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Groups/PlanningTabView.axaml.cs
@@ -30,6 +30,7 @@ public partial class PlanningTabView : UserControl
vm.OnPickMoveTarget = ShowMoveLessonDialog;
vm.OnShowLesson = ShowLessonViewerDialog;
vm.OnGenerateLessonSeries = ShowGenerateLessonSeriesDialog;
+ vm.OnAiAssist = ShowAiAssistDialog;
}
}
@@ -147,4 +148,20 @@ public partial class PlanningTabView : UserControl
App.Services.GetRequiredService().ShowSuccess(result.Summary);
return ok ? dialogVm.Result : null;
}
+
+ private async Task ShowAiAssistDialog(Unit unit)
+ {
+ var dialogVm = new AiAssistDialogViewModel(
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ App.Services.GetRequiredService(),
+ unit);
+
+ var dialog = new AiAssistDialog { DataContext = dialogVm };
+ var owner = TopLevel.GetTopLevel(this) as Window;
+ if (owner is null) return false;
+
+ await dialog.ShowDialog(owner);
+ return dialogVm.Result;
+ }
}
diff --git a/LehrerApp.Desktop/Views/Settings/CompetencyCatalogImportDialog.axaml b/LehrerApp.Desktop/Views/Settings/CompetencyCatalogImportDialog.axaml
new file mode 100644
index 0000000..5e316ff
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Settings/CompetencyCatalogImportDialog.axaml
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Settings/CompetencyCatalogImportDialog.axaml.cs b/LehrerApp.Desktop/Views/Settings/CompetencyCatalogImportDialog.axaml.cs
new file mode 100644
index 0000000..7d0dc64
--- /dev/null
+++ b/LehrerApp.Desktop/Views/Settings/CompetencyCatalogImportDialog.axaml.cs
@@ -0,0 +1,18 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using LehrerApp.Desktop.ViewModels.Settings;
+
+namespace LehrerApp.Desktop.Views.Settings;
+
+public partial class CompetencyCatalogImportDialog : Window
+{
+ public CompetencyCatalogImportDialog() => InitializeComponent();
+
+ private void OnApply(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is CompetencyCatalogImportViewModel vm && vm.TryApply())
+ Close(true);
+ }
+
+ private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
+}
diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
index 7c56c9c..40796c7 100644
--- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
+++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml
@@ -711,6 +711,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs
index 0f1c422..5bb62ad 100644
--- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs
+++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml.cs
@@ -64,7 +64,16 @@ public partial class SettingsView : UserControl
if (files.Count == 0) return;
var json = await File.ReadAllTextAsync(files[0].Path.LocalPath);
- vm.ImportCatalog(json);
+ var preview = vm.PrepareCatalogImport(json);
+ if (preview is null) return;
+
+ var owner = topLevel as Window;
+ if (owner is null) return;
+ var dialog = new CompetencyCatalogImportDialog
+ {
+ DataContext = new CompetencyCatalogImportViewModel(vm, preview),
+ };
+ await dialog.ShowDialog(owner);
}
private async void OnExportClick(object? sender, RoutedEventArgs e)
diff --git a/LehrerApp.Tests/CompetencyCatalogImportServiceTests.cs b/LehrerApp.Tests/CompetencyCatalogImportServiceTests.cs
new file mode 100644
index 0000000..39e0341
--- /dev/null
+++ b/LehrerApp.Tests/CompetencyCatalogImportServiceTests.cs
@@ -0,0 +1,198 @@
+using LehrerApp.Core.Interfaces;
+using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
+using Xunit;
+
+namespace LehrerApp.Tests;
+
+public sealed class CompetencyCatalogImportServiceTests
+{
+ private static readonly Guid SubjectId = Guid.NewGuid();
+
+ [Fact]
+ public void Analyze_UngueltigeDatei_VeraendertDenKatalogNicht()
+ {
+ var repository = RepositoryWithExistingCatalog();
+ var service = new CompetencyCatalogImportService(repository);
+
+ Assert.Throws(() =>
+ service.Analyze("{ kaputt", SubjectId, "Mathematik", 7));
+
+ Assert.False(repository.ReplaceWasCalled);
+ Assert.Equal("Alte Beschreibung", repository.Current.Single().Items.Single().Description);
+ }
+
+ [Fact]
+ public void Analyze_ErkenntNeueUnveraenderteUndKonflikte()
+ {
+ var service = new CompetencyCatalogImportService(RepositoryWithExistingCatalog());
+
+ var preview = service.Analyze(JsonWithConflictAndNewItem(), SubjectId, "Mathematik", 7);
+
+ Assert.Equal(0, preview.NewDomains);
+ Assert.Equal(1, preview.NewCompetencies);
+ Assert.Single(preview.Conflicts);
+ Assert.Equal("item:A1", preview.Conflicts[0].Id);
+ }
+
+ [Fact]
+ public void Apply_MergeBehaeltBeiKonfliktStandardmaessigDenVorhandenenWert()
+ {
+ var repository = RepositoryWithExistingCatalog();
+ var service = new CompetencyCatalogImportService(repository);
+ var preview = service.Analyze(JsonWithConflictAndNewItem(), SubjectId, "Mathematik", 7);
+
+ service.Apply(preview, CompetencyCatalogImportMode.Merge);
+
+ Assert.True(repository.ReplaceWasCalled);
+ var items = repository.Current.Single().Items;
+ Assert.Equal("Alte Beschreibung", items.Single(x => x.Code == "A1").Description);
+ Assert.Equal("Neue Kompetenz", items.Single(x => x.Code == "A2").Description);
+ }
+
+ [Fact]
+ public void Apply_MergeKannImportiertenKonfliktwertUebernehmen()
+ {
+ var repository = RepositoryWithExistingCatalog();
+ var service = new CompetencyCatalogImportService(repository);
+ var preview = service.Analyze(JsonWithConflictAndNewItem(), SubjectId, "Mathematik", 7);
+
+ service.Apply(preview, CompetencyCatalogImportMode.Merge, new HashSet { "item:A1" });
+
+ Assert.Equal("Importierte Beschreibung",
+ repository.Current.Single().Items.Single(x => x.Code == "A1").Description);
+ }
+
+ [Fact]
+ public void Apply_NeuerBereichMitVorhandenemCode_ErzeugtKeinenDoppeltenKompetenzcode()
+ {
+ var repository = RepositoryWithExistingCatalog();
+ var service = new CompetencyCatalogImportService(repository);
+ const string json = """
+ { "domains": [
+ { "code": "B", "name": "Neuer Bereich", "competencies": [
+ { "code": "A1", "description": "Andere Fassung" }
+ ] }
+ ] }
+ """;
+ var preview = service.Analyze(json, SubjectId, "Mathematik", 7);
+
+ service.Apply(preview, CompetencyCatalogImportMode.Merge);
+
+ Assert.Single(repository.Current.SelectMany(x => x.Items), x => x.Code == "A1");
+ Assert.Equal("Alte Beschreibung",
+ repository.Current.SelectMany(x => x.Items).Single(x => x.Code == "A1").Description);
+ }
+
+ [Fact]
+ public void Apply_ReplaceErsetztDenGesamtenAusgewaehltenKatalog()
+ {
+ var repository = RepositoryWithExistingCatalog();
+ var service = new CompetencyCatalogImportService(repository);
+ var preview = service.Analyze(JsonWithConflictAndNewItem(), SubjectId, "Mathematik", 7);
+
+ service.Apply(preview, CompetencyCatalogImportMode.Replace);
+
+ Assert.Equal(2, repository.Current.Single().Items.Count);
+ Assert.Equal("Importierte Beschreibung",
+ repository.Current.Single().Items.Single(x => x.Code == "A1").Description);
+ }
+
+ [Fact]
+ public void Apply_NachZwischenzeitlicherAenderung_VerlangtNeueVorschau()
+ {
+ var repository = RepositoryWithExistingCatalog();
+ var service = new CompetencyCatalogImportService(repository);
+ var preview = service.Analyze(JsonWithConflictAndNewItem(), SubjectId, "Mathematik", 7);
+ repository.Current[0].UpdatedAt = repository.Current[0].UpdatedAt.AddSeconds(1);
+
+ var exception = Assert.Throws(() =>
+ service.Apply(preview, CompetencyCatalogImportMode.Merge));
+
+ Assert.Contains("Vorschau", exception.Message);
+ Assert.False(repository.ReplaceWasCalled);
+ }
+
+ [Fact]
+ public void Analyze_AbweichendesFachUndKlassenstufe_ErzeugtWarnungen()
+ {
+ var service = new CompetencyCatalogImportService(RepositoryWithExistingCatalog());
+ var json = JsonWithConflictAndNewItem()
+ .Replace("Mathematik", "Deutsch")
+ .Replace("\"gradeLevel\": 7", "\"gradeLevel\": 8");
+
+ var preview = service.Analyze(json, SubjectId, "Mathematik", 7);
+
+ Assert.Equal(2, preview.Warnings.Count);
+ }
+
+ [Fact]
+ public void Analyze_DoppelterKompetenzcode_WirdAbgelehnt()
+ {
+ var service = new CompetencyCatalogImportService(RepositoryWithExistingCatalog());
+ const string json = """
+ { "domains": [
+ { "name": "A", "competencies": [{ "code": "X1", "description": "Eins" }] },
+ { "name": "B", "competencies": [{ "code": "X1", "description": "Zwei" }] }
+ ] }
+ """;
+
+ Assert.Throws(() =>
+ service.Analyze(json, SubjectId, "Mathematik", 7));
+ }
+
+ private static InMemoryCompetencyDomainRepository RepositoryWithExistingCatalog() => new(
+ [
+ new CompetencyDomain
+ {
+ SubjectId = SubjectId,
+ GradeLevel = 7,
+ Code = "A",
+ Name = "Arithmetik",
+ Items =
+ [
+ new CompetencyItem { Code = "A1", Description = "Alte Beschreibung" },
+ ],
+ },
+ ]);
+
+ private static string JsonWithConflictAndNewItem() => """
+ {
+ "subject": "Mathematik",
+ "gradeLevel": 7,
+ "domains": [
+ {
+ "code": "A",
+ "name": "Arithmetik",
+ "competencies": [
+ { "code": "A1", "description": "Importierte Beschreibung" },
+ { "code": "A2", "description": "Neue Kompetenz" }
+ ]
+ }
+ ]
+ }
+ """;
+
+ private sealed class InMemoryCompetencyDomainRepository(List initial)
+ : ICompetencyDomainRepository
+ {
+ public List Current { get; private set; } = initial;
+ public bool ReplaceWasCalled { get; private set; }
+
+ public List GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
+ Current.Where(x => x.SubjectId == subjectId && x.GradeLevel == gradeLevel).ToList();
+ public CompetencyDomain? GetById(Guid id) => Current.FirstOrDefault(x => x.Id == id);
+
+ public void Save(CompetencyDomain domain) => Current.Add(domain);
+ public void Delete(Guid id) => Current.RemoveAll(x => x.Id == id);
+ public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
+ Current.RemoveAll(x => x.SubjectId == subjectId && x.GradeLevel == gradeLevel);
+
+ public void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List domains)
+ {
+ ReplaceWasCalled = true;
+ Current.RemoveAll(x => x.SubjectId == subjectId && x.GradeLevel == gradeLevel);
+ Current.AddRange(domains);
+ }
+ }
+}
diff --git a/TODO.md b/TODO.md
index edd589f..5c87d2e 100644
--- a/TODO.md
+++ b/TODO.md
@@ -646,11 +646,46 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
`CompetencyDomain`/`CompetencyItem`-Katalog (siehe Kompetenzen-Tab in den Einstellungen),
bisher nur für Klausuraufgaben (`ExamTask.CompetencyCodes`) verknüpft, nicht für
Verlaufsplan-Phasen.
-- [ ] **4.5.9** KI-gestützte Planungsunterstützung über eine Schnittstelle zu einer LLM-API, um
+- [x] **4.5.9** KI-gestützte Planungsunterstützung über eine Schnittstelle zu einer LLM-API, um
Einheiten/Stunden mit Hilfe vorzuschlagen und weiterzuentwickeln. Bedarf eines abgesicherten
Zwischenelements auf dem eigenen Server (Ablösung/Verbesserung des bisherigen
PHP-Zwischenelements für Elternbriefe) mit interner Abrechnung/Nutzungskontrolle, damit der
API-Schlüssel nicht im Client landet.
+
+ **Umsetzung:** Neuer Button "🤖 KI-Unterstützung" im Planungs-Tab (nur bei ausgewählter
+ Einheit und aktivierter KI-Unterstützung) öffnet `AiAssistDialog`: Freitext-Anweisung an die
+ KI, Antwort wird als Liste vorgeschlagener Stunden mit Checkbox zur Übernahme angezeigt
+ (nichts wird automatisch übernommen). Export/Import-Schema als eigene DTOs in
+ `LehrerApp.Core/AiPlanning/AiPlanningDtos.cs`, bewusst getrennt von den internen
+ Domänenmodellen — ein Wire-Vertrag mit einem externen System muss unabhängig von internen
+ Refactors abwärtskompatibel bleiben. Zentrale Absicherung in
+ `AiPlanningService.ApplyResponse`: eine von der KI zurückgegebene Lesson-Id, die zu keiner
+ tatsächlich zur Einheit gehörenden Lesson passt, wird NIE als Update interpretiert (sonst
+ könnte eine halluzinierte Id im schlimmsten Fall eine fremde Lesson überschreiben), sondern
+ immer als neue Lesson mit frischer Id behandelt — mit dediziertem Test abgesichert.
+
+ Neue Einstellungen-Tab "KI-Unterstützung": Aktivieren-Schalter, Benutzername/Passwort →
+ Anmelden tauscht das Passwort gegen ein Bearer-Token (nie das Passwort selbst persistiert).
+ Das Token liegt lokal AES-256-verschlüsselt über das bestehende `SyncCrypto` (gleiches
+ Verfahren wie beim Sync-Schlüssel) — besser als die bisherige Klartext-Ablage der
+ Sync-Server-URL, aber mangels Betriebssystem-Schlüsselbund kein vollständiger Schutz gegen
+ jemanden mit Zugriff auf den App-Datenordner; bewusste Abwägung, kein Aufschub aus Unklarheit.
+
+ Neues PHP-Backend in `ai-backend/` (im Repo, aber nicht Teil von `LehrerApp.sln` — ein
+ separat deploytes System, dessen Schema aber mit den DTOs synchron bleiben soll). MySQL-Schema
+ mit `users`/`tokens`/`transactions`, mehrnutzerfähig von Anfang an. Abrechnung nach echten
+ Token-Kosten (Input-/Output-Token × Preistabelle) statt Pauschalpreis, mit `SELECT ... FOR
+ UPDATE` gegen Race Conditions beim Guthabenabzug. Provider-Schnittstelle vorbereitet für
+ mehrere LLM-Anbieter, in dieser Runde aber **nur Anthropic konkret implementiert** — OpenAI
+ bewusst zurückgestellt, da aktuelle Preise/API-Version zum Zeitpunkt der Implementierung
+ nicht verifiziert werden konnten (kein Aufschub aus Unklarheit über den Bedarf, sondern um
+ keine falsch berechneten Kosten zu riskieren). Kein Admin-UI für Guthaben-Aufladung — bei der
+ aktuellen Nutzerzahl reicht ein dokumentierter manueller SQL-Befehl (`ai-backend/README.md`).
+
+ **Nicht ohne echtes Deployment + echten API-Key verifizierbar** (siehe `ai-backend/README.md`):
+ ob Anthropic zuverlässig valides JSON im erwarteten Schema liefert, ob die berechneten
+ Kosten exakt mit der echten Abrechnung übereinstimmen, sowie die komplette Kette
+ Desktop → Backend → Anthropic unter echten Netzwerkbedingungen.
- [ ] **4.5.10** Falls doch ein schlanker Companion-/WebApp-Client entstehen soll: bewusst
**minimaler** Funktionsumfang — nur Wochenraster ansehen, eine Stunde verschieben, oder eine
Stunde als "Umplanung nötig" flaggen. Kein Editor für Einheiten/Kompetenzen/KI-Planung dort.
@@ -934,12 +969,18 @@ Format dokumentiert in [Kompetenzkatalog-KI-Prompt.md](docs/Kompetenzkatalog-KI-
### 8.1 Katalogverwaltung
- [ ] **8.1.1** Kompetenzen innerhalb eines Bereichs umsortieren (`SortOrder` bearbeitbar machen).
-- [ ] **8.1.2** Katalog exportieren (JSON) — Gegenstück zum vorhandenen Import.
+- [x] **8.1.2** Katalog exportieren (JSON) — Gegenstück zum vorhandenen Import.
+ Bereits über „JSON exportieren“ in den Einstellungen umgesetzt; Fach, Klassenstufe,
+ Bereiche und Kompetenzen werden vollständig ausgegeben.
- [ ] **8.1.3** Katalog von einer Jahrgangsstufe in eine andere kopieren.
-- [ ] **8.1.4** Import-Konflikte behandeln: Merge statt Ersetzen anbieten.
+- [x] **8.1.4** Import-Konflikte behandeln: Merge statt Ersetzen anbieten.
+ Umgesetzt mit vollständiger Validierung vor dem Schreiben, Importvorschau, sicherem Merge
+ als Vorauswahl, Einzelentscheidung je Konflikt und bestätigungspflichtigem atomarem Ersetzen.
### 8.2 Verwendung im Unterricht
-- [ ] **8.2.1** Kompetenzen einer Unterrichtseinheit zuordnen (siehe 4.1.3).
+- [x] **8.2.1** Kompetenzen einer Unterrichtseinheit zuordnen (siehe 4.1.3).
+ Bereits im Unterrichtseinheiten-Dialog über die Kompetenz-Auswahl umgesetzt und in
+ `Unit.Competencies` gespeichert.
- [x] **8.2.2** Kompetenzen einzelnen Klausuraufgaben zuordnen — umgesetzt mit 1.2.4.
- [ ] **8.2.3** Abdeckungsübersicht: welche Kompetenzen wurden im Schuljahr behandelt/geprüft?
diff --git a/ai-backend/.htaccess b/ai-backend/.htaccess
new file mode 100644
index 0000000..42bfd7a
--- /dev/null
+++ b/ai-backend/.htaccess
@@ -0,0 +1,19 @@
+# Sperrt alles, was kein öffentlicher Endpunkt ist. Nur login.php / status.php / plan.php sollen
+# von außen aufrufbar sein. Siehe README.md — noch robuster ist es, config.php/db.php/schema.sql/
+# providers/ komplett außerhalb des Webroots abzulegen, falls das Hosting das erlaubt.
+
+
+ Require all denied
+
+
+
+ Require all denied
+
+
+
+ Require all denied
+
+
+RewriteEngine On
+RewriteRule ^providers/ - [F,L]
+RewriteRule ^scripts/ - [F,L]
diff --git a/ai-backend/README.md b/ai-backend/README.md
new file mode 100644
index 0000000..0e3b395
--- /dev/null
+++ b/ai-backend/README.md
@@ -0,0 +1,81 @@
+# KI-Backend (TODO 4.5.9)
+
+Kleines PHP-Zwischenelement, das die KI-gestützte Planungsunterstützung des Desktop-Clients
+absichert: der LLM-API-Key liegt nur hier auf dem Server, der Desktop-Client bekommt nur ein
+eigenes Bearer-Token gegen dieses Backend und ein pro Nutzer geführtes Guthaben. Deployment
+macht der Nutzer selbst — dieses README beschreibt die nötigen Schritte.
+
+## Voraussetzungen
+
+- PHP 8.1 oder neuer (nutzt `never`-Rückgabetypen, `match`, First-Class-Callable-Syntax nicht,
+ aber typisierte Properties/Enums nicht zwingend — 8.1 ist die sichere Untergrenze).
+- PHP-Erweiterungen: `pdo_mysql`, `curl`, `json` (bei den meisten Hosting-Paketen bereits dabei).
+- Eine MySQL- oder MariaDB-Datenbank.
+- Ein API-Key für Anthropic (`https://console.anthropic.com`).
+
+## Einrichtung
+
+1. Datenbank anlegen und `schema.sql` importieren:
+ ```bash
+ mysql -u -p < schema.sql
+ ```
+2. `config.example.php` nach `config.php` kopieren und ausfüllen (DB-Zugang, Anthropic-API-Key,
+ ggf. die Preistabelle gegen die aktuelle Anthropic-Preisseite prüfen — Preise ändern sich).
+ `config.php` ist in `.gitignore` und darf nie committet werden.
+3. Ersten Nutzer anlegen (weitere Lehrer später genauso):
+ ```bash
+ php scripts/create-user.php sebastian "einStarkesPasswort" 10.00
+ ```
+ Der dritte Parameter ist das Startguthaben in USD, optional (Standard 0).
+4. Den kompletten `ai-backend/`-Ordner auf den PHP-Server hochladen. **Empfehlung:** `config.php`,
+ `db.php`, `schema.sql`, `providers/` und `scripts/` außerhalb des öffentlichen Webroots ablegen,
+ falls das Hosting das erlaubt (Pfade in den `require`-Aufrufen entsprechend anpassen) — robuster
+ als sich allein auf die mitgelieferte `.htaccess` zu verlassen, die nur bei Apache mit
+ aktiviertem `mod_rewrite`/erlaubten `.htaccess`-Overrides greift.
+5. In `LehrerApp.Desktop/AppBootstrapper.cs` die Konstante `AiBackendUrl` auf die tatsächlich
+ deployte Domain setzen und die App neu bauen.
+6. In der App unter Einstellungen → KI-Unterstützung aktivieren und mit dem angelegten Nutzer
+ anmelden.
+
+## Smoke-Test ohne echten API-Key
+
+`plan.php` liest die Umgebungsvariable `AI_BACKEND_FAKE_PROVIDER` — bei `1` wird statt eines
+echten Anthropic-Aufrufs `providers/FakeProvider.php` verwendet (liefert eine feste,
+schema-valide Test-Antwort). Damit lassen sich Login, Guthabenprüfung und die 402-Pfade lokal
+prüfen, ohne echte Kosten zu verursachen:
+
+```bash
+AI_BACKEND_FAKE_PROVIDER=1 php -S localhost:8000 -t .
+```
+
+Dann z.B.:
+
+```bash
+curl -X POST http://localhost:8000/login.php \
+ -d '{"username":"sebastian","password":"einStarkesPasswort"}'
+
+curl http://localhost:8000/status.php -H "Authorization: Bearer "
+```
+
+**Wichtig:** `AI_BACKEND_FAKE_PROVIDER` niemals auf dem produktiven Server setzen — sonst bekommt
+die App nur die feste Test-Antwort statt echter KI-Vorschläge.
+
+## Was hiermit NICHT geprüft ist
+
+- Ob Anthropic zuverlässig valides JSON im erwarteten Schema liefert (reine Prompt-Qualitätsfrage,
+ nur mit dem echten API-Key zu beurteilen).
+- Ob die berechneten Kosten exakt mit der tatsächlichen Anthropic-Abrechnung übereinstimmen.
+- TLS/`.htaccess`-Wirksamkeit und PHP-Version/Erweiterungen auf dem tatsächlichen Hosting.
+- Die komplette Kette Desktop → dieses Backend → Anthropic unter echten Netzwerkbedingungen.
+
+## Guthaben aufladen
+
+Für den aktuellen Umfang (ein bis wenige Nutzer) reicht ein manueller SQL-Befehl:
+
+```sql
+UPDATE users SET balance_usd = balance_usd + 10.00 WHERE username = 'sebastian';
+INSERT INTO transactions (user_id, type, cost_usd, balance_after)
+ SELECT id, 'topup', -10.00, balance_usd FROM users WHERE username = 'sebastian';
+```
+
+(Kein Admin-UI in dieser Ausbaustufe — bei Bedarf später ergänzbar.)
diff --git a/ai-backend/config.example.php b/ai-backend/config.example.php
new file mode 100644
index 0000000..644bfa3
--- /dev/null
+++ b/ai-backend/config.example.php
@@ -0,0 +1,34 @@
+ [
+ 'host' => 'localhost',
+ 'name' => 'lehrerapp_ai',
+ 'user' => 'CHANGE_ME',
+ 'pass' => 'CHANGE_ME',
+ ],
+
+ // Nur 'anthropic' ist aktuell fertig implementiert (siehe providers/AnthropicProvider.php).
+ // Die Provider-Schnittstelle ist so gebaut, dass ein OpenAiProvider später ergänzt werden
+ // kann — bewusst nicht in dieser Runde, da aktuelle OpenAI-Preise/API-Version zum Zeitpunkt
+ // der Implementierung nicht verifiziert wurden (siehe TODO.md, Nachtrag zu 4.5.9).
+ 'llm_provider' => 'anthropic',
+
+ 'anthropic' => [
+ 'api_key' => 'sk-ant-CHANGE_ME',
+ 'model' => 'claude-sonnet-5',
+ ],
+
+ // Sicherheitsnetz gegen ausufernde Antworten (und damit Kosten) pro Anfrage.
+ 'max_output_tokens' => 8000,
+
+ // USD je 1 Million Token, getrennt nach Input/Output. Vor dem produktiven Einsatz gegen die
+ // aktuelle Anthropic-Preisseite gegenprüfen — Preise ändern sich.
+ 'pricing' => [
+ 'claude-sonnet-5' => ['input' => 3.00, 'output' => 15.00],
+ 'claude-opus-5' => ['input' => 5.00, 'output' => 25.00],
+ 'claude-haiku-4-5' => ['input' => 1.00, 'output' => 5.00],
+ ],
+];
diff --git a/ai-backend/db.php b/ai-backend/db.php
new file mode 100644
index 0000000..4ca34a0
--- /dev/null
+++ b/ai-backend/db.php
@@ -0,0 +1,50 @@
+ PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_EMULATE_PREPARES => false,
+ ]);
+}
+
+/**
+ * Bearer-Token aus dem Authorization-Header lesen, gegen tokens.token_hash prüfen
+ * (SHA-256, der Klartext wird nie gespeichert) und den zugehörigen aktiven User zurückgeben.
+ * Sendet bei fehlendem/ungültigem/abgelaufenem Token selbst eine 401-Antwort und beendet das Skript.
+ */
+function ai_backend_authenticate(PDO $pdo): array
+{
+ $header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
+ if (!preg_match('/^Bearer\s+(.+)$/i', $header, $m)) {
+ ai_backend_fail(401, 'Kein gültiges Token übermittelt.');
+ }
+ $tokenHash = hash('sha256', $m[1]);
+
+ $stmt = $pdo->prepare(
+ 'SELECT u.* FROM users u
+ JOIN tokens t ON t.user_id = u.id
+ WHERE t.token_hash = ? AND t.expires_at > NOW() AND u.is_active = 1
+ LIMIT 1'
+ );
+ $stmt->execute([$tokenHash]);
+ $user = $stmt->fetch();
+ if (!$user) {
+ ai_backend_fail(401, 'Ungültiges oder abgelaufenes Token.');
+ }
+ return $user;
+}
+
+/** Einheitliche Fehlerantwort als JSON, beendet danach das Skript. */
+function ai_backend_fail(int $httpStatus, string $message): never
+{
+ http_response_code($httpStatus);
+ header('Content-Type: application/json');
+ echo json_encode(['error' => $message]);
+ exit;
+}
diff --git a/ai-backend/login.php b/ai-backend/login.php
new file mode 100644
index 0000000..acb56ff
--- /dev/null
+++ b/ai-backend/login.php
@@ -0,0 +1,34 @@
+prepare('SELECT * FROM users WHERE username = ? AND is_active = 1 LIMIT 1');
+$stmt->execute([$username]);
+$user = $stmt->fetch();
+
+if (!$user || !password_verify($password, $user['password_hash'])) {
+ ai_backend_fail(401, 'Benutzername oder Passwort ist falsch.');
+}
+
+$token = bin2hex(random_bytes(32));
+$tokenHash = hash('sha256', $token);
+$expiresAt = (new DateTimeImmutable('+30 days'))->format('Y-m-d H:i:s');
+
+$stmt = $pdo->prepare('INSERT INTO tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)');
+$stmt->execute([$user['id'], $tokenHash, $expiresAt]);
+
+echo json_encode(['token' => $token]);
diff --git a/ai-backend/plan.php b/ai-backend/plan.php
new file mode 100644
index 0000000..ffe5463
--- /dev/null
+++ b/ai-backend/plan.php
@@ -0,0 +1,119 @@
+",
+ "date": "",
+ "lessonNumber": ,
+ "topic": "",
+ "startTime": "",
+ "phases": [
+ {
+ "name": "",
+ "durationMinutes": ,
+ "activity": "",
+ "material": "",
+ "shorthand": "",
+ "alternativePathName": ""
+ }
+ ],
+ "homework": "",
+ "reflection": ""
+ }
+ ],
+ "summary": ""
+}
+
+WICHTIG: Um eine bestehende Stunde zu ändern, gib exakt deren "id" aus der Eingabe zurück. Für
+eine neu vorgeschlagene Stunde setze "id" auf null. Erfinde niemals eine Id, die nicht in der
+Eingabe stand. Nutze für "alternativePathName" nur Namen aus dem mitgelieferten Katalog.
+PROMPT;
+
+$userContent = json_encode($body);
+
+$useFake = getenv('AI_BACKEND_FAKE_PROVIDER') === '1'; // nur für lokale Smoke-Tests, siehe README.md
+if ($useFake) {
+ $provider = new FakeProvider();
+ $modelKey = 'fake';
+} else {
+ $providerName = $config['llm_provider'];
+ if ($providerName !== 'anthropic') {
+ ai_backend_fail(500, "Provider '$providerName' ist nicht implementiert.");
+ }
+ $modelKey = $config['anthropic']['model'];
+ $provider = new AnthropicProvider($config['anthropic']['api_key'], $modelKey);
+}
+
+try {
+ $result = $provider->sendMessage($systemPrompt, $userContent, $config['max_output_tokens']);
+} catch (RuntimeException $e) {
+ ai_backend_fail(502, $e->getMessage());
+}
+
+$pricing = $config['pricing'][$modelKey] ?? ($useFake ? ['input' => 0, 'output' => 0] : null);
+if ($pricing === null) {
+ ai_backend_fail(500, "Kein Preis für Modell '$modelKey' konfiguriert.");
+}
+$cost = ($result['inputTokens'] / 1_000_000 * $pricing['input'])
+ + ($result['outputTokens'] / 1_000_000 * $pricing['output']);
+
+// Guthaben abziehen und Transaktion protokollieren — mit Zeilensperre, damit zwei gleichzeitige
+// Anfragen desselben Nutzers das Guthaben nicht versehentlich unter 0 drücken können.
+$pdo->beginTransaction();
+try {
+ $stmt = $pdo->prepare('SELECT balance_usd FROM users WHERE id = ? FOR UPDATE');
+ $stmt->execute([$user['id']]);
+ $currentBalance = (float) $stmt->fetchColumn();
+
+ if ($currentBalance - $cost < 0) {
+ $pdo->rollBack();
+ ai_backend_fail(402, 'Guthaben würde durch diese Anfrage negativ werden.');
+ }
+
+ $newBalance = $currentBalance - $cost;
+ $pdo->prepare('UPDATE users SET balance_usd = ? WHERE id = ?')->execute([$newBalance, $user['id']]);
+ $pdo->prepare(
+ 'INSERT INTO transactions (user_id, type, model, input_tokens, output_tokens, cost_usd, balance_after)
+ VALUES (?, "usage", ?, ?, ?, ?, ?)'
+ )->execute([$user['id'], $modelKey, $result['inputTokens'], $result['outputTokens'], $cost, $newBalance]);
+
+ $pdo->commit();
+} catch (Throwable $e) {
+ $pdo->rollBack();
+ throw $e;
+}
+
+// Erst NACH der Abrechnung validieren: die Token wurden real verbraucht, das wird auch dann
+// verrechnet, wenn die KI kein valides JSON geliefert hat (siehe Planungsdokument).
+$parsed = json_decode($result['content'], true);
+if (!is_array($parsed) || !isset($parsed['lessons'])) {
+ ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.');
+}
+
+echo json_encode($parsed);
diff --git a/ai-backend/providers/AnthropicProvider.php b/ai-backend/providers/AnthropicProvider.php
new file mode 100644
index 0000000..309e0e7
--- /dev/null
+++ b/ai-backend/providers/AnthropicProvider.php
@@ -0,0 +1,63 @@
+ true,
+ CURLOPT_POST => true,
+ CURLOPT_HTTPHEADER => [
+ 'content-type: application/json',
+ 'x-api-key: ' . $this->apiKey,
+ 'anthropic-version: 2023-06-01',
+ ],
+ CURLOPT_POSTFIELDS => json_encode([
+ 'model' => $this->model,
+ 'max_tokens' => $maxTokens,
+ 'system' => $systemPrompt,
+ 'messages' => [['role' => 'user', 'content' => $userContent]],
+ ]),
+ CURLOPT_TIMEOUT => 90,
+ ]);
+ $raw = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $curlError = curl_error($ch);
+ curl_close($ch);
+
+ if ($raw === false) {
+ throw new RuntimeException("Anthropic-Anfrage fehlgeschlagen: $curlError");
+ }
+ if ($httpCode >= 400) {
+ throw new RuntimeException("Anthropic-API-Fehler (HTTP $httpCode): " . substr((string) $raw, 0, 500));
+ }
+
+ $data = json_decode((string) $raw, true);
+ if (!is_array($data)) {
+ throw new RuntimeException('Anthropic-Antwort konnte nicht als JSON gelesen werden.');
+ }
+ if (($data['stop_reason'] ?? null) === 'refusal') {
+ throw new RuntimeException('Die KI hat die Anfrage abgelehnt.');
+ }
+
+ $text = '';
+ foreach (($data['content'] ?? []) as $block) {
+ if (($block['type'] ?? null) === 'text') {
+ $text .= $block['text'];
+ }
+ }
+
+ return [
+ 'content' => $text,
+ 'inputTokens' => (int) ($data['usage']['input_tokens'] ?? 0),
+ 'outputTokens' => (int) ($data['usage']['output_tokens'] ?? 0),
+ ];
+ }
+}
diff --git a/ai-backend/providers/FakeProvider.php b/ai-backend/providers/FakeProvider.php
new file mode 100644
index 0000000..e0f96c8
--- /dev/null
+++ b/ai-backend/providers/FakeProvider.php
@@ -0,0 +1,36 @@
+ json_encode([
+ 'lessons' => [
+ [
+ 'id' => null,
+ 'date' => null,
+ 'lessonNumber' => null,
+ 'topic' => 'Fake-Vorschlag zum Testen',
+ 'startTime' => null,
+ 'phases' => [],
+ 'homework' => null,
+ 'reflection' => null,
+ ],
+ ],
+ 'summary' => 'Antwort des FakeProvider (kein echter KI-Aufruf).',
+ ]),
+ 'inputTokens' => 42,
+ 'outputTokens' => 17,
+ ];
+ }
+}
diff --git a/ai-backend/providers/ProviderInterface.php b/ai-backend/providers/ProviderInterface.php
new file mode 100644
index 0000000..41d74ae
--- /dev/null
+++ b/ai-backend/providers/ProviderInterface.php
@@ -0,0 +1,10 @@
+ [startguthaben_usd]
+
+require_once __DIR__ . '/../db.php';
+
+if (PHP_SAPI !== 'cli') {
+ fwrite(STDERR, "Nur über die Kommandozeile ausführen.\n");
+ exit(1);
+}
+
+[, $username, $password, $startBalance] = array_pad($argv, 4, null);
+if ($username === null || $password === null) {
+ fwrite(STDERR, "Aufruf: php scripts/create-user.php [startguthaben_usd]\n");
+ exit(1);
+}
+
+$config = require __DIR__ . '/../config.php';
+$pdo = ai_backend_db($config);
+
+$hash = password_hash($password, PASSWORD_DEFAULT);
+$balance = $startBalance !== null ? (float) $startBalance : 0.0;
+
+$stmt = $pdo->prepare('INSERT INTO users (username, password_hash, balance_usd) VALUES (?, ?, ?)');
+$stmt->execute([$username, $hash, $balance]);
+
+echo "Nutzer '$username' angelegt, Startguthaben: " . number_format($balance, 2) . " USD.\n";
diff --git a/ai-backend/status.php b/ai-backend/status.php
new file mode 100644
index 0000000..adef64f
--- /dev/null
+++ b/ai-backend/status.php
@@ -0,0 +1,12 @@
+ (float) $user['balance_usd']]);
diff --git a/docs/Kompetenzkatalog-KI-Prompt.md b/docs/Kompetenzkatalog-KI-Prompt.md
index 6c67c4e..312d398 100644
--- a/docs/Kompetenzkatalog-KI-Prompt.md
+++ b/docs/Kompetenzkatalog-KI-Prompt.md
@@ -48,7 +48,10 @@ einfügen kannst.
### Hinweise
- `code` darf leer sein (`""`), ist aber für die spätere Zuordnung hilfreich
- Die Reihenfolge der Bereiche und Kompetenzen im JSON wird beibehalten
-- Beim Import werden **alle bestehenden Einträge** für Fach + Klassenstufe ersetzt
+- Vor dem Import zeigt die App neue, unveränderte und widersprüchliche Einträge an
+- Standardmäßig wird der Katalog **zusammengeführt**; bei Konflikten bleibt zunächst die
+ vorhandene Fassung ausgewählt und kann einzeln durch die importierte Fassung ersetzt werden
+- Das vollständige Ersetzen des Katalogs ist weiterhin möglich, muss aber ausdrücklich bestätigt werden
- Mehrere Klassenstufen = mehrere JSON-Dateien
---
@@ -119,7 +122,8 @@ Typische Bereiche nach Bildungsplan BW:
2. KI-Prompt mit Fach und Klassenstufe anpassen und ausführen
3. JSON aus der KI-Antwort kopieren und in eine `.json`-Datei speichern
4. In der App: **Einstellungen → Kompetenzkataloge** → Fach + Klassenstufe wählen → **Import JSON**
-5. Nach dem Import erscheinen die Bereiche und Kompetenzen sofort in der Übersicht
+5. Vorschau und mögliche Konflikte prüfen; in der Regel **Zusammenführen (empfohlen)** wählen
+6. Nach dem Import erscheinen die Bereiche und Kompetenzen sofort in der Übersicht
---