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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user