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