KI-gestützte Planungsunterstützung (4.5.9) + Kompetenzkatalog-Import (8.1.2)

KI-Unterstützung: neuer Einstellungen-Tab (Anmeldung, Guthaben) und Button im
Planungs-Tab, der Einheiten+Stunden als JSON an ein neues PHP-Backend (ai-backend/)
sendet und die Antwort als prüfbare Vorschlagsliste zurückbringt. Provider-Aufruf,
Guthabenverwaltung und Abrechnung nach echten Token-Kosten laufen serverseitig, der
Desktop-Client sieht nie einen LLM-API-Key. Zentral abgesichert: eine von der KI
zurückgegebene Stunden-Id, die zu keiner echten Lesson der Einheit passt, wird nie
als Update übernommen, sondern immer als neue Stunde behandelt.

Kompetenzkatalog-Import (8.1.2): JSON-Export/Import für Kompetenzkataloge.
This commit is contained in:
2026-08-16 01:31:14 +02:00
parent 2b4fda7bb3
commit 8495e1b8d0
40 changed files with 2326 additions and 51 deletions
+4
View File
@@ -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
@@ -0,0 +1,86 @@
namespace LehrerApp.Core.AiPlanning;
/// <summary>
/// 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.
/// </summary>
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<string> 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<AiCompetencyDomain> CompetencyCatalog { get; set; } = [];
public List<AiAlternativePath> AlternativePathCatalog { get; set; } = [];
public List<AiLesson> Lessons { get; set; } = [];
}
public class AiCompetencyDomain
{
public string Name { get; set; } = "";
public List<AiCompetencyItem> 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; } = "";
}
/// <summary>
/// <see cref="Id"/> 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.
/// </summary>
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<AiPhaseStep> 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<AiLesson> Lessons { get; set; } = [];
public string? Summary { get; set; }
}
@@ -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<CompetencyDomain> domains);
}
public interface IShorthandCodeRepository
{
@@ -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<CompetencyDomain> ImportedDomains { get; init; }
public required string ExistingFingerprint { get; init; }
public List<string> Warnings { get; init; } = [];
public List<CompetencyCatalogImportConflict> Conflicts { get; init; } = [];
public int NewDomains { get; init; }
public int NewCompetencies { get; init; }
public int UnchangedCompetencies { get; init; }
}
/// <summary>Analysiert Kompetenzkataloge vollständig, bevor bestehende Daten verändert werden.</summary>
public sealed class CompetencyCatalogImportService(ICompetencyDomainRepository repository)
{
public CompetencyCatalogImportPreview Analyze(
string json, Guid subjectId, string subjectName, int gradeLevel)
{
CatalogDto dto;
try
{
dto = JsonSerializer.Deserialize<CatalogDto>(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<CompetencyDomain>();
var domainKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var competencyCodes = new HashSet<string>(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<CompetencyItem>();
var localKeys = new HashSet<string>(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<string>();
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<CompetencyCatalogImportConflict>();
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<string>? 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<CompetencyDomain> result = mode == CompetencyCatalogImportMode.Replace
? preview.ImportedDomains.Select((d, index) => CloneAsNew(d, preview.SubjectId, preview.GradeLevel, index)).ToList()
: Merge(current, preview, useImportedConflicts ?? new HashSet<string>());
repository.ReplaceForSubjectAndGrade(preview.SubjectId, preview.GradeLevel, result);
}
private static List<CompetencyDomain> Merge(List<CompetencyDomain> existing,
CompetencyCatalogImportPreview preview, IReadOnlySet<string> 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<CompetencyDomain> 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<DomainDto>? 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<ItemDto>? Competencies { get; set; }
}
private sealed class ItemDto
{
[JsonPropertyName("code")] public string? Code { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
}
}
@@ -603,4 +603,21 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
.ToList())
db.CompetencyDomains.Delete(d.Id);
}
public void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List<CompetencyDomain> 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);
}
});
}
}
@@ -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<AiLesson> { 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<AiLesson> { 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<AiLesson> { 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<AiLesson>
{
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);
}
}
@@ -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());
}
}
+36 -5
View File
@@ -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<Student> all) : IStudentRepository
{
private readonly Dictionary<Guid, StudentReferenceSummary> _references = [];
@@ -209,11 +231,20 @@ public class FakeSubjects(List<Subject> all) : ISubjectRepository
public class FakeCompetencyDomains : ICompetencyDomainRepository
{
public List<CompetencyDomain> 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<CompetencyDomain> _all = [];
public void Add(CompetencyDomain d) => _all.Add(d);
public List<CompetencyDomain> 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<CompetencyDomain> domains)
{
DeleteBySubjectAndGrade(subjectId, gradeLevel);
_all.AddRange(domains);
}
}
public class FakeShorthandCodes(List<ShorthandCode> all) : IShorthandCodeRepository
@@ -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);
@@ -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);
@@ -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";
+12
View File
@@ -27,6 +27,13 @@ public static class AppBootstrapper
public static string DbPath { get; private set; } = "";
public static string AppDataPath { get; private set; } = "";
/// <summary>
/// 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.
/// </summary>
public const string AiBackendUrl = "https://REPLACE_ME.example.com/";
/// <summary>
/// Vor <see cref="BuildServices"/> 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<AiPlanningService>();
// ── Sync (optional nur wenn Server konfiguriert) ────────────────────
services.AddSingleton(_ => new EventQueue(queuePath));
services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService<EventQueue>()));
@@ -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;
/// <summary>Fehler beim Aufruf des KI-Backends, Message ist bereits deutsch und nutzergerichtet.</summary>
public class AiBackendException(string userMessage) : Exception(userMessage);
/// <summary>
/// Orchestriert die KI-gestützte Planungsunterstützung (TODO 4.5.9): baut aus einer <see cref="Unit"/>
/// den Export-Kontext, ruft das externe PHP-Backend (ai-backend/) auf und wendet dessen Antwort auf
/// die Lessons an. Das Backend selbst ruft serverseitig eine LLM-API auf — der Desktop-Client sieht
/// nie einen LLM-API-Key, nur das eigene Bearer-Token gegen das PHP-Backend.
/// </summary>
public class AiPlanningService(HttpClient http, ILessonRepository lessons,
IGroupRepository groups, ISubjectRepository subjects,
ICompetencyDomainRepository competencyDomains, IAlternativeLessonPathRepository altPaths)
{
// Wire-Format zum PHP-Backend ist camelCase (siehe ai-backend/) — beide Seiten sind hier
// im eigenen Zugriff, daher bewusst konsistent camelCase statt der C#-üblichen PascalCase-Defaults.
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
public async Task<string> LoginAsync(string username, string password)
{
HttpResponseMessage resp;
try
{
resp = await http.PostAsJsonAsync("login.php", new { username, password }, JsonOptions);
}
catch (HttpRequestException)
{
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
}
if (resp.StatusCode == HttpStatusCode.Unauthorized)
throw new AiBackendException("Benutzername oder Passwort ist falsch.");
if (!resp.IsSuccessStatusCode)
throw new AiBackendException("Anmeldung fehlgeschlagen. Bitte später erneut versuchen.");
var result = await resp.Content.ReadFromJsonAsync<LoginResult>(JsonOptions);
return result?.Token ?? throw new AiBackendException("Unerwartete Antwort des KI-Dienstes.");
}
public async Task<decimal> GetBalanceAsync(string token)
{
using var req = new HttpRequestMessage(HttpMethod.Get, "status.php");
req.Headers.Authorization = new("Bearer", token);
HttpResponseMessage resp;
try { resp = await http.SendAsync(req); }
catch (HttpRequestException)
{
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
}
if (resp.StatusCode == HttpStatusCode.Unauthorized)
throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
if (!resp.IsSuccessStatusCode)
throw new AiBackendException("Guthaben konnte nicht abgerufen werden.");
var result = await resp.Content.ReadFromJsonAsync<BalanceResult>(JsonOptions);
return result?.BalanceUsd ?? 0m;
}
/// <summary>Rein (nur Repository-Lesezugriffe, kein Netzwerk) — testbar mit Fakes.</summary>
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<AiPlanningResponse> 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<AiPlanningResponse>(JsonOptions);
return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
}
catch (Exception ex) when (ex is not AiBackendException)
{
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
}
}
/// <summary>
/// Rein (nur Repository-Lesezugriff für den Alternativpfad-Katalog, kein Schreiben) — testbar
/// mit Fakes. Gibt die zu speichernden Lesson-Objekte zurück; der Aufrufer ruft
/// <see cref="ILessonRepository.Save"/> je Eintrag auf. Eine akzeptierte AiLesson mit einer Id,
/// die keiner tatsächlich zur Einheit gehörenden Lesson entspricht, wird NIE als Update
/// interpretiert, sondern immer als neue Lesson behandelt (Anti-Halluzinations-Absicherung).
/// </summary>
public List<Lesson> ApplyResponse(Unit unit, List<AiLesson> 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<Lesson>();
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; } }
}
@@ -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; }
}
/// <summary>
/// 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 <see cref="SyncCrypto"/>
/// 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.
/// </summary>
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<string>(_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<AiSettingsConfig>(File.ReadAllText(_configPath))
?? new AiSettingsConfig();
}
catch { /* beschädigte Konfiguration -> Standardwert */ }
return new AiSettingsConfig();
}
}
@@ -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<Lesson, Task<MoveLessonTarget?>>? OnPickMoveTarget { get; set; }
public Func<Lesson, Task>? OnShowLesson { get; set; }
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
public Func<Unit, Task<bool>>? 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 (<see cref="AiAssistDialogViewModel"/>), 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. <see cref="AiLesson.Id"/> unterscheidet neu/geändert (siehe
/// AiPlanningDtos.cs), <see cref="IsNew"/> 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<AiLessonReviewItem> 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
@@ -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<ImportModeOption> Modes { get; }
public ObservableCollection<CompetencyImportConflictItem> Conflicts { get; } = [];
public IReadOnlyList<string> 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;
}
}
@@ -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<SupervisionDutyItem> 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<CatalogDto>(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<string> 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
@@ -0,0 +1,52 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.AiAssistDialog"
x:DataType="vm:AiAssistDialogViewModel"
Title="KI-Unterstützung"
Width="480" Height="560" MinWidth="420" MinHeight="420"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<ScrollViewer Grid.Row="0">
<StackPanel Spacing="14" Margin="0,0,12,0">
<TextBlock Text="KI-Unterstützung" Classes="dialogtitle"/>
<TextBlock Text="{Binding UnitSummary}" FontSize="12" Opacity="0.6"/>
<StackPanel Spacing="4" IsVisible="{Binding !HasResults}">
<TextBlock Text="Anweisung" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Instruction}" AcceptsReturn="True" TextWrapping="Wrap" Height="120"
PlaceholderText="z.B. Ergänze zwei weitere Stunden zum Thema Redoxreaktionen mit steigendem Anspruch."
IsEnabled="{Binding !IsBusy}"/>
</StackPanel>
<TextBlock Text="Anfrage läuft…" FontSize="12" Opacity="0.6" IsVisible="{Binding IsBusy}"/>
<TextBlock Text="{Binding ErrorMessage}" Foreground="Red" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding ErrorMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<StackPanel Spacing="10" IsVisible="{Binding HasResults}">
<TextBlock Text="{Binding Summary}" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding Summary, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="Vorschläge (angehakt wird übernommen)" FontSize="12" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding ReviewItems}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:AiLessonReviewItem">
<CheckBox Content="{Binding DisplayLabel}" IsChecked="{Binding Accepted}" Margin="0,3"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Die KI hat keine Stunden vorgeschlagen." Opacity="0.6" FontSize="12"
IsVisible="{Binding !ReviewItems.Count}"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Anfrage senden" HorizontalAlignment="Stretch" Click="OnSend"
IsVisible="{Binding !HasResults}" IsEnabled="{Binding !IsBusy}"/>
<Button Grid.Column="2" Content="Übernehmen" HorizontalAlignment="Stretch" Click="OnApply"
IsVisible="{Binding HasResults}"/>
</Grid>
</Grid>
</Window>
@@ -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);
}
}
@@ -15,6 +15,7 @@
<Button Content="Bearbeiten" Command="{Binding EditUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="Als Vorlage kopieren" Command="{Binding CopyUnitCommand}"/>
<Button Content="Löschen" Command="{Binding DeleteUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
<Button Content="🤖 KI-Unterstützung" Command="{Binding AiAssistCommand}"/>
</StackPanel>
</Grid>
@@ -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<NotificationService>().ShowSuccess(result.Summary);
return ok ? dialogVm.Result : null;
}
private async Task<bool> ShowAiAssistDialog(Unit unit)
{
var dialogVm = new AiAssistDialogViewModel(
App.Services.GetRequiredService<AiPlanningService>(),
App.Services.GetRequiredService<AiSettingsService>(),
App.Services.GetRequiredService<ILessonRepository>(),
unit);
var dialog = new AiAssistDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return false;
await dialog.ShowDialog<bool>(owner);
return dialogVm.Result;
}
}
@@ -0,0 +1,87 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Settings"
x:Class="LehrerApp.Desktop.Views.Settings.CompetencyCatalogImportDialog"
x:DataType="vm:CompetencyCatalogImportViewModel"
Title="Kompetenzkatalog importieren"
Width="760" Height="720" MinWidth="620" MinHeight="560"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,*,Auto" Margin="24,20">
<StackPanel Grid.Row="0" Spacing="10">
<TextBlock Text="Import prüfen" Classes="dialogtitle"/>
<TextBlock Text="{Binding Target}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Summary}" FontSize="12" Opacity="0.7"/>
<StackPanel IsVisible="{Binding HasWarnings}" Spacing="4">
<TextBlock Text="Hinweise zur Datei" FontWeight="SemiBold" Foreground="#B06A00"/>
<ItemsControl ItemsSource="{Binding Warnings}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" TextWrapping="Wrap" FontSize="12" Foreground="#B06A00"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Importverfahren" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Modes}" SelectedItem="{Binding SelectedMode}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:ImportModeOption">
<TextBlock Text="{Binding Label}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<Border IsVisible="{Binding IsMerge}" Background="#143B82F6" CornerRadius="5" Padding="10,8">
<TextBlock Text="Neue Einträge werden ergänzt. Bei Konflikten bleibt ohne abweichende Auswahl die vorhandene Fassung erhalten."
TextWrapping="Wrap" FontSize="12"/>
</Border>
<Border IsVisible="{Binding IsReplace}" Background="#20C62828" CornerRadius="5" Padding="10,8">
<StackPanel Spacing="8">
<TextBlock Text="Achtung: Alle vorhandenen Bereiche und Kompetenzen dieses Fachs und dieser Klassenstufe werden durch den Dateiinhalt ersetzt."
TextWrapping="Wrap" Foreground="#C62828" FontWeight="SemiBold"/>
<CheckBox Content="Ich möchte den vorhandenen Katalog vollständig ersetzen."
IsChecked="{Binding ReplaceConfirmed}"/>
</StackPanel>
</Border>
</StackPanel>
<Grid Grid.Row="1" RowDefinitions="Auto,*" Margin="0,14,0,0" IsVisible="{Binding HasConflicts}">
<TextBlock Grid.Row="0" Text="Konflikte" FontSize="14" FontWeight="SemiBold" Margin="0,0,0,6"/>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Conflicts}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CompetencyImportConflictItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="5" Padding="12" Margin="0,0,0,8">
<StackPanel Spacing="6">
<TextBlock Text="{Binding Location}" FontWeight="SemiBold"/>
<Grid ColumnDefinitions="110,*" RowDefinitions="Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Vorhanden:" FontSize="12" Opacity="0.65"/>
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding ExistingValue}" TextWrapping="Wrap" FontSize="12"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Importiert:" FontSize="12" Opacity="0.65"/>
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding ImportedValue}" TextWrapping="Wrap" FontSize="12"/>
</Grid>
<CheckBox Content="Importierte Fassung übernehmen"
IsChecked="{Binding UseImported}"
IsVisible="{Binding $parent[Window].((vm:CompetencyCatalogImportViewModel)DataContext).IsMerge}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
<StackPanel Grid.Row="2" Spacing="10" Margin="0,14,0,0">
<TextBlock Text="{Binding Error}" Foreground="Red" TextWrapping="Wrap"
IsVisible="{Binding Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,10,*">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Import anwenden" HorizontalAlignment="Stretch" Click="OnApply"/>
</Grid>
</StackPanel>
</Grid>
</Window>
@@ -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);
}
@@ -711,6 +711,43 @@
</ScrollViewer>
</ContentPage>
<!-- Tab: KI-Unterstützung (4.5.9) -->
<ContentPage Header="KI-Unterstützung">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="KI-gestützte Planungsunterstützung" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Ermöglicht KI-Vorschläge für Unterrichtseinheiten über einen Zwischendienst auf dem eigenen Server (kein API-Schlüssel im Client). Jede Anfrage verbraucht Guthaben."/>
<CheckBox Content="KI-Unterstützung aktivieren" IsChecked="{Binding AiEnabled}"/>
<StackPanel Spacing="8" IsVisible="{Binding !AiIsLoggedIn}">
<StackPanel Spacing="4">
<TextBlock Text="Benutzername" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AiUsername}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Passwort" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AiPassword}" PasswordChar="●"/>
</StackPanel>
<TextBlock Text="{Binding AiLoginError}" Foreground="Red" FontSize="12"
IsVisible="{Binding AiLoginError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Anmelden" Command="{Binding AiLoginCommand}" HorizontalAlignment="Left"/>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding AiIsLoggedIn}">
<TextBlock FontSize="13" FontWeight="SemiBold">
<Run Text="Angemeldet als: "/><Run Text="{Binding AiUsername}"/>
</TextBlock>
<TextBlock Text="{Binding AiBalanceDisplay}" FontSize="13"/>
<Button Content="Abmelden" Command="{Binding AiLogoutCommand}" HorizontalAlignment="Left"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>
</TabbedPage>
</Grid>
</UserControl>
@@ -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<bool>(owner);
}
private async void OnExportClick(object? sender, RoutedEventArgs e)
@@ -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<InvalidDataException>(() =>
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<string> { "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<InvalidOperationException>(() =>
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<InvalidDataException>(() =>
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<CompetencyDomain> initial)
: ICompetencyDomainRepository
{
public List<CompetencyDomain> Current { get; private set; } = initial;
public bool ReplaceWasCalled { get; private set; }
public List<CompetencyDomain> 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<CompetencyDomain> domains)
{
ReplaceWasCalled = true;
Current.RemoveAll(x => x.SubjectId == subjectId && x.GradeLevel == gradeLevel);
Current.AddRange(domains);
}
}
}
+45 -4
View File
@@ -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?
+19
View File
@@ -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.
<FilesMatch "^(config(\.example)?\.php|db\.php)$">
Require all denied
</FilesMatch>
<Files "schema.sql">
Require all denied
</Files>
<FilesMatch "\.md$">
Require all denied
</FilesMatch>
RewriteEngine On
RewriteRule ^providers/ - [F,L]
RewriteRule ^scripts/ - [F,L]
+81
View File
@@ -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 <user> -p <datenbankname> < 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 <token aus login.php>"
```
**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.)
+34
View File
@@ -0,0 +1,34 @@
<?php
// Kopieren nach config.php und mit echten Werten füllen. config.php selbst ist in .gitignore
// eingetragen und darf nie committet werden.
return [
'db' => [
'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],
],
];
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/** Baut eine PDO-Verbindung aus config.php auf. */
function ai_backend_db(array $config): PDO
{
$db = $config['db'];
$dsn = "mysql:host={$db['host']};dbname={$db['name']};charset=utf8mb4";
return new PDO($dsn, $db['user'], $db['pass'], [
PDO::ATTR_ERRMODE => 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;
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
$pdo = ai_backend_db($config);
$body = json_decode(file_get_contents('php://input'), true);
$username = trim((string) ($body['username'] ?? ''));
$password = (string) ($body['password'] ?? '');
if ($username === '' || $password === '') {
ai_backend_fail(400, 'Benutzername und Passwort erforderlich.');
}
$stmt = $pdo->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]);
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/providers/AnthropicProvider.php';
require_once __DIR__ . '/providers/FakeProvider.php';
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
$pdo = ai_backend_db($config);
$user = ai_backend_authenticate($pdo);
if ((float) $user['balance_usd'] <= 0) {
ai_backend_fail(402, 'Kein Guthaben mehr vorhanden.');
}
$body = json_decode(file_get_contents('php://input'), true);
if (!is_array($body) || !isset($body['unit'])) {
ai_backend_fail(400, 'Ungültige Anfrage.');
}
$systemPrompt = <<<PROMPT
Du bist ein Assistent für die Unterrichtsplanung einer Lehrkraft. Du bekommst eine
Unterrichtseinheit (JSON) mit ihren bisherigen Stunden sowie eine freie Anweisung der Lehrkraft.
Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Freitext davor/danach) in genau diesem Schema:
{
"lessons": [
{
"id": "<GUID der bestehenden Stunde ODER null für eine neue Stunde>",
"date": "<TT.MM.JJJJ oder null>",
"lessonNumber": <Zahl oder null>,
"topic": "<Thema>",
"startTime": "<HH:mm oder null>",
"phases": [
{
"name": "<Phasenname>",
"durationMinutes": <Zahl>,
"activity": "<Tätigkeit>",
"material": "<Material>",
"shorthand": "<Kurzsymbol>",
"alternativePathName": "<Name aus dem übergebenen Katalog oder null für den Hauptweg>"
}
],
"homework": "<Hausaufgabe oder null>",
"reflection": "<Reflexion oder null>"
}
],
"summary": "<kurze menschenlesbare Zusammenfassung, was du getan hast>"
}
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);
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ProviderInterface.php';
/** Ruft die Anthropic Messages API direkt per curl auf — bewusst ohne SDK-Abhängigkeit. */
class AnthropicProvider implements ProviderInterface
{
public function __construct(private string $apiKey, private string $model) {}
public function sendMessage(string $systemPrompt, string $userContent, int $maxTokens): array
{
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => 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),
];
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ProviderInterface.php';
/**
* Für lokale Smoke-Tests ohne echten API-Key (siehe README.md) — liefert eine feste,
* valide AiPlanningResponse-JSON zurück statt einen echten LLM-Aufruf zu machen. Niemals als
* Standard-Provider in config.php eintragen, nur über eine explizite lokale Umgebungsvariable
* (siehe plan.php) für Entwicklungszwecke aktivieren.
*/
class FakeProvider implements ProviderInterface
{
public function sendMessage(string $systemPrompt, string $userContent, int $maxTokens): array
{
return [
'content' => 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,
];
}
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
interface ProviderInterface
{
/**
* @return array{content: string, inputTokens: int, outputTokens: int}
*/
public function sendMessage(string $systemPrompt, string $userContent, int $maxTokens): array;
}
+35
View File
@@ -0,0 +1,35 @@
-- KI-Backend (TODO 4.5.9) — MySQL/MariaDB-Schema.
-- Multi-user-fähig von Anfang an, auch wenn zunächst nur ein Nutzer angelegt wird.
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL, -- password_hash()-Ausgabe (bcrypt/argon2)
balance_usd DECIMAL(10,4) NOT NULL DEFAULT 0.0000,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_active TINYINT(1) NOT NULL DEFAULT 1
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash VARCHAR(64) NOT NULL, -- SHA-256 des Tokens; der Klartext wird nie gespeichert
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_token_hash (token_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS transactions (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
type ENUM('usage', 'topup') NOT NULL,
model VARCHAR(64) NULL, -- z.B. 'claude-sonnet-5'; NULL bei topup
input_tokens INT NULL,
output_tokens INT NULL,
cost_usd DECIMAL(10,6) NOT NULL,
balance_after DECIMAL(10,4) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_created (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
// CLI-Helfer zum Anlegen des ersten Nutzers (oder weiterer Lehrer später), siehe README.md.
// Aufruf: php scripts/create-user.php <benutzername> <passwort> [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 <benutzername> <passwort> [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";
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
$pdo = ai_backend_db($config);
$user = ai_backend_authenticate($pdo);
echo json_encode(['balanceUsd' => (float) $user['balance_usd']]);
+6 -2
View File
@@ -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
---