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:
@@ -42,6 +42,10 @@ server.txt
|
|||||||
# ── API / Docker ──────────────────────────────────────────────────────────────
|
# ── API / Docker ──────────────────────────────────────────────────────────────
|
||||||
# Lokale Datenhaltung des Servers
|
# Lokale Datenhaltung des Servers
|
||||||
LehrerApp.Api/data/
|
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/
|
docker/data/
|
||||||
|
|
||||||
# Umgebungsvariablen – .env.example ins Repo, .env nicht
|
# 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 Save(CompetencyDomain domain);
|
||||||
void Delete(Guid id);
|
void Delete(Guid id);
|
||||||
void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel);
|
void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel);
|
||||||
|
void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List<CompetencyDomain> domains);
|
||||||
}
|
}
|
||||||
public interface IShorthandCodeRepository
|
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())
|
.ToList())
|
||||||
db.CompetencyDomains.Delete(d.Id);
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,33 @@
|
|||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Tests;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
// Einfache In-Memory-Fakes der Repository-Schnittstellen, damit ViewModel-Tests ohne echte
|
// Einfache In-Memory-Fakes der Repository-Schnittstellen, damit ViewModel-Tests ohne echte
|
||||||
// LiteDB-Anbindung laufen. Bewusst schlank gehalten: nur was die getesteten ViewModels brauchen.
|
// 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
|
public class FakeStudents(List<Student> all) : IStudentRepository
|
||||||
{
|
{
|
||||||
private readonly Dictionary<Guid, StudentReferenceSummary> _references = [];
|
private readonly Dictionary<Guid, StudentReferenceSummary> _references = [];
|
||||||
@@ -209,11 +231,20 @@ public class FakeSubjects(List<Subject> all) : ISubjectRepository
|
|||||||
|
|
||||||
public class FakeCompetencyDomains : ICompetencyDomainRepository
|
public class FakeCompetencyDomains : ICompetencyDomainRepository
|
||||||
{
|
{
|
||||||
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => [];
|
private readonly List<CompetencyDomain> _all = [];
|
||||||
public CompetencyDomain? GetById(Guid id) => null;
|
public void Add(CompetencyDomain d) => _all.Add(d);
|
||||||
public void Save(CompetencyDomain domain) { }
|
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||||
public void Delete(Guid id) { }
|
_all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel).ToList();
|
||||||
public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel) { }
|
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
|
public class FakeShorthandCodes(List<ShorthandCode> all) : IShorthandCodeRepository
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ public sealed class GroupDetailViewModelTests
|
|||||||
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
|
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
|
||||||
students, memberships, groups, new FakeCompetencyDomains()),
|
students, memberships, groups, new FakeCompetencyDomains()),
|
||||||
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
|
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.LoadGroup(group.Id);
|
||||||
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
|
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -18,7 +19,8 @@ public class PlanningTabViewModelTests
|
|||||||
var units = new FakeUnits();
|
var units = new FakeUnits();
|
||||||
var lessons = new FakeLessons();
|
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);
|
vm.Initialize(groupId);
|
||||||
|
|
||||||
return (vm, units, lessons, groupId);
|
return (vm, units, lessons, groupId);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public sealed class SettingsViewModelTests
|
|||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
||||||
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
||||||
new LetterTemplateService(tempPath));
|
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -101,7 +101,7 @@ public sealed class SettingsViewModelTests
|
|||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
||||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath));
|
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService());
|
||||||
|
|
||||||
vm.SelectedStateName = "Bayern";
|
vm.SelectedStateName = "Bayern";
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ public sealed class SettingsViewModelTests
|
|||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
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].StartText = "08:00";
|
||||||
vm.PeriodTimes[0].EndText = "08:45";
|
vm.PeriodTimes[0].EndText = "08:45";
|
||||||
@@ -149,7 +149,7 @@ public sealed class SettingsViewModelTests
|
|||||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
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].StartText = "08:45";
|
||||||
vm.PeriodTimes[0].EndText = "08:00";
|
vm.PeriodTimes[0].EndText = "08:00";
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ public static class AppBootstrapper
|
|||||||
public static string DbPath { get; private set; } = "";
|
public static string DbPath { get; private set; } = "";
|
||||||
public static string AppDataPath { 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>
|
/// <summary>
|
||||||
/// Vor <see cref="BuildServices"/> gesetzt, wenn die Datenbank passwortgeschützt ist
|
/// Vor <see cref="BuildServices"/> gesetzt, wenn die Datenbank passwortgeschützt ist
|
||||||
/// (siehe App.axaml.cs: Passwort-Abfrage vor dem Öffnen der Datenbank).
|
/// (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 WorkloadSettingsService(appData));
|
||||||
services.AddSingleton(_ => new LetterTemplateService(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) ────────────────────
|
// ── Sync (optional – nur wenn Server konfiguriert) ────────────────────
|
||||||
services.AddSingleton(_ => new EventQueue(queuePath));
|
services.AddSingleton(_ => new EventQueue(queuePath));
|
||||||
services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService<EventQueue>()));
|
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.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Core.AiPlanning;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
private readonly IGroupRepository _groups;
|
private readonly IGroupRepository _groups;
|
||||||
private readonly ISubjectRepository _subjects;
|
private readonly ISubjectRepository _subjects;
|
||||||
private readonly ICompetencyDomainRepository _competencyDomains;
|
private readonly ICompetencyDomainRepository _competencyDomains;
|
||||||
|
private readonly AiSettingsService _aiSettings;
|
||||||
|
|
||||||
private Guid _groupId;
|
private Guid _groupId;
|
||||||
|
|
||||||
@@ -66,13 +69,15 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
public Func<Lesson, Task<MoveLessonTarget?>>? OnPickMoveTarget { get; set; }
|
public Func<Lesson, Task<MoveLessonTarget?>>? OnPickMoveTarget { get; set; }
|
||||||
public Func<Lesson, Task>? OnShowLesson { get; set; }
|
public Func<Lesson, Task>? OnShowLesson { get; set; }
|
||||||
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
|
public Func<Unit, Task<LessonSeriesResult?>>? OnGenerateLessonSeries { get; set; }
|
||||||
|
public Func<Unit, Task<bool>>? OnAiAssist { get; set; }
|
||||||
|
|
||||||
public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
|
public PlanningTabViewModel(IUnitRepository units, ILessonRepository lessons,
|
||||||
IGroupRepository groups, ISubjectRepository subjects,
|
IGroupRepository groups, ISubjectRepository subjects,
|
||||||
ICompetencyDomainRepository competencyDomains)
|
ICompetencyDomainRepository competencyDomains, AiSettingsService aiSettings)
|
||||||
{
|
{
|
||||||
_units = units; _lessons = lessons; _groups = groups;
|
_units = units; _lessons = lessons; _groups = groups;
|
||||||
_subjects = subjects; _competencyDomains = competencyDomains;
|
_subjects = subjects; _competencyDomains = competencyDomains;
|
||||||
|
_aiSettings = aiSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Initialize(Guid groupId, bool isReadOnly = false)
|
public void Initialize(Guid groupId, bool isReadOnly = false)
|
||||||
@@ -121,6 +126,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
CopyUnitCommand.NotifyCanExecuteChanged();
|
CopyUnitCommand.NotifyCanExecuteChanged();
|
||||||
AddLessonCommand.NotifyCanExecuteChanged();
|
AddLessonCommand.NotifyCanExecuteChanged();
|
||||||
GenerateLessonSeriesCommand.NotifyCanExecuteChanged();
|
GenerateLessonSeriesCommand.NotifyCanExecuteChanged();
|
||||||
|
AiAssistCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadLessons()
|
private void LoadLessons()
|
||||||
@@ -144,6 +150,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
|
|
||||||
private bool HasSelectedUnit() => SelectedUnit is not null;
|
private bool HasSelectedUnit() => SelectedUnit is not null;
|
||||||
private bool HasSelectedLesson() => SelectedLesson is not null;
|
private bool HasSelectedLesson() => SelectedLesson is not null;
|
||||||
|
private bool CanAiAssist() => SelectedUnit is not null && _aiSettings.Enabled;
|
||||||
|
|
||||||
// ── Einheiten (4.1) ────────────────────────────────────────────────────────
|
// ── Einheiten (4.1) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -253,6 +260,15 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
if (result is not null) LoadUnits();
|
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))]
|
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||||||
private async Task EditLesson()
|
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) ────────────
|
// ── Dialog: Einheit als Vorlage in andere Gruppe kopieren (4.1.4) ────────────
|
||||||
|
|
||||||
public partial class CopyUnitDialogViewModel : ObservableObject
|
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.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Data;
|
using LehrerApp.Data;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Planning;
|
using LehrerApp.Desktop.ViewModels.Planning;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
@@ -154,12 +155,24 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
public string[] WeekdayOptions { get; } = WeekdayDisplay.Options;
|
public string[] WeekdayOptions { get; } = WeekdayDisplay.Options;
|
||||||
public ObservableCollection<SupervisionDutyItem> SupervisionDuties { get; } = [];
|
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 ───────────────────────────────────────────────────────────
|
// ── Konstruktor ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private readonly ISchoolHolidayRepository _schoolHolidays;
|
private readonly ISchoolHolidayRepository _schoolHolidays;
|
||||||
private readonly SchoolCalendarSettingsService _calendarSettings;
|
private readonly SchoolCalendarSettingsService _calendarSettings;
|
||||||
private readonly PeriodScheduleService _periodSchedule;
|
private readonly PeriodScheduleService _periodSchedule;
|
||||||
private readonly ISupervisionDutyRepository _supervisionDuties;
|
private readonly ISupervisionDutyRepository _supervisionDuties;
|
||||||
|
private readonly AiSettingsService _aiSettings;
|
||||||
|
private readonly AiPlanningService _aiPlanning;
|
||||||
|
private readonly CompetencyCatalogImportService _catalogImport;
|
||||||
|
|
||||||
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
|
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
|
||||||
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
|
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
|
||||||
@@ -168,7 +181,8 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
IDocumentationRepository documentation, IStudentRepository students,
|
IDocumentationRepository documentation, IStudentRepository students,
|
||||||
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays,
|
||||||
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
|
||||||
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates)
|
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
|
||||||
|
AiSettingsService aiSettings, AiPlanningService aiPlanning)
|
||||||
{
|
{
|
||||||
_subjects = subjects;
|
_subjects = subjects;
|
||||||
_domainRepo = domainRepo;
|
_domainRepo = domainRepo;
|
||||||
@@ -188,6 +202,9 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
_periodSchedule = periodSchedule;
|
_periodSchedule = periodSchedule;
|
||||||
_supervisionDuties = supervisionDuties;
|
_supervisionDuties = supervisionDuties;
|
||||||
_letterTemplates = letterTemplates;
|
_letterTemplates = letterTemplates;
|
||||||
|
_aiSettings = aiSettings;
|
||||||
|
_aiPlanning = aiPlanning;
|
||||||
|
_catalogImport = new CompetencyCatalogImportService(domainRepo);
|
||||||
LoadSubjects();
|
LoadSubjects();
|
||||||
LoadShorthandCodes();
|
LoadShorthandCodes();
|
||||||
LoadGradingKeyTemplates();
|
LoadGradingKeyTemplates();
|
||||||
@@ -203,6 +220,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
LoadPeriodTimes();
|
LoadPeriodTimes();
|
||||||
LoadSupervisionDuties();
|
LoadSupervisionDuties();
|
||||||
LoadLetterTemplates();
|
LoadLetterTemplates();
|
||||||
|
LoadAiSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
|
// ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
|
||||||
@@ -292,6 +310,58 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
SupervisionDuties.Remove(item);
|
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 ─────────────────────────────────────
|
// ── Stundenraster: Laden / Speichern ─────────────────────────────────────
|
||||||
|
|
||||||
private void LoadPeriodTimes()
|
private void LoadPeriodTimes()
|
||||||
@@ -697,46 +767,36 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
|
|
||||||
// ── JSON Import / Export ──────────────────────────────────────────────────
|
// ── 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
|
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 = "";
|
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()
|
public string ExportCatalog()
|
||||||
{
|
{
|
||||||
var dto = new CatalogDto
|
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="Bearbeiten" Command="{Binding EditUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||||
<Button Content="Als Vorlage kopieren" Command="{Binding CopyUnitCommand}"/>
|
<Button Content="Als Vorlage kopieren" Command="{Binding CopyUnitCommand}"/>
|
||||||
<Button Content="Löschen" Command="{Binding DeleteUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
<Button Content="Löschen" Command="{Binding DeleteUnitCommand}" IsEnabled="{Binding !IsReadOnly}"/>
|
||||||
|
<Button Content="🤖 KI-Unterstützung" Command="{Binding AiAssistCommand}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public partial class PlanningTabView : UserControl
|
|||||||
vm.OnPickMoveTarget = ShowMoveLessonDialog;
|
vm.OnPickMoveTarget = ShowMoveLessonDialog;
|
||||||
vm.OnShowLesson = ShowLessonViewerDialog;
|
vm.OnShowLesson = ShowLessonViewerDialog;
|
||||||
vm.OnGenerateLessonSeries = ShowGenerateLessonSeriesDialog;
|
vm.OnGenerateLessonSeries = ShowGenerateLessonSeriesDialog;
|
||||||
|
vm.OnAiAssist = ShowAiAssistDialog;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,4 +148,20 @@ public partial class PlanningTabView : UserControl
|
|||||||
App.Services.GetRequiredService<NotificationService>().ShowSuccess(result.Summary);
|
App.Services.GetRequiredService<NotificationService>().ShowSuccess(result.Summary);
|
||||||
return ok ? dialogVm.Result : null;
|
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>
|
</ScrollViewer>
|
||||||
</ContentPage>
|
</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>
|
</TabbedPage>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -64,7 +64,16 @@ public partial class SettingsView : UserControl
|
|||||||
|
|
||||||
if (files.Count == 0) return;
|
if (files.Count == 0) return;
|
||||||
var json = await File.ReadAllTextAsync(files[0].Path.LocalPath);
|
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)
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -646,11 +646,46 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
|
|||||||
`CompetencyDomain`/`CompetencyItem`-Katalog (siehe Kompetenzen-Tab in den Einstellungen),
|
`CompetencyDomain`/`CompetencyItem`-Katalog (siehe Kompetenzen-Tab in den Einstellungen),
|
||||||
bisher nur für Klausuraufgaben (`ExamTask.CompetencyCodes`) verknüpft, nicht für
|
bisher nur für Klausuraufgaben (`ExamTask.CompetencyCodes`) verknüpft, nicht für
|
||||||
Verlaufsplan-Phasen.
|
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
|
Einheiten/Stunden mit Hilfe vorzuschlagen und weiterzuentwickeln. Bedarf eines abgesicherten
|
||||||
Zwischenelements auf dem eigenen Server (Ablösung/Verbesserung des bisherigen
|
Zwischenelements auf dem eigenen Server (Ablösung/Verbesserung des bisherigen
|
||||||
PHP-Zwischenelements für Elternbriefe) mit interner Abrechnung/Nutzungskontrolle, damit der
|
PHP-Zwischenelements für Elternbriefe) mit interner Abrechnung/Nutzungskontrolle, damit der
|
||||||
API-Schlüssel nicht im Client landet.
|
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
|
- [ ] **4.5.10** Falls doch ein schlanker Companion-/WebApp-Client entstehen soll: bewusst
|
||||||
**minimaler** Funktionsumfang — nur Wochenraster ansehen, eine Stunde verschieben, oder eine
|
**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.
|
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 Katalogverwaltung
|
||||||
- [ ] **8.1.1** Kompetenzen innerhalb eines Bereichs umsortieren (`SortOrder` bearbeitbar machen).
|
- [ ] **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.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 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.
|
- [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?
|
- [ ] **8.2.3** Abdeckungsübersicht: welche Kompetenzen wurden im Schuljahr behandelt/geprüft?
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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.)
|
||||||
@@ -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],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
@@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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";
|
||||||
@@ -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']]);
|
||||||
@@ -48,7 +48,10 @@ einfügen kannst.
|
|||||||
### Hinweise
|
### Hinweise
|
||||||
- `code` darf leer sein (`""`), ist aber für die spätere Zuordnung hilfreich
|
- `code` darf leer sein (`""`), ist aber für die spätere Zuordnung hilfreich
|
||||||
- Die Reihenfolge der Bereiche und Kompetenzen im JSON wird beibehalten
|
- 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
|
- 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
|
2. KI-Prompt mit Fach und Klassenstufe anpassen und ausführen
|
||||||
3. JSON aus der KI-Antwort kopieren und in eine `.json`-Datei speichern
|
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**
|
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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user