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
@@ -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