KI-gestützte Planungsunterstützung (4.5.9) + Kompetenzkatalog-Import (8.1.2)
KI-Unterstützung: neuer Einstellungen-Tab (Anmeldung, Guthaben) und Button im Planungs-Tab, der Einheiten+Stunden als JSON an ein neues PHP-Backend (ai-backend/) sendet und die Antwort als prüfbare Vorschlagsliste zurückbringt. Provider-Aufruf, Guthabenverwaltung und Abrechnung nach echten Token-Kosten laufen serverseitig, der Desktop-Client sieht nie einen LLM-API-Key. Zentral abgesichert: eine von der KI zurückgegebene Stunden-Id, die zu keiner echten Lesson der Einheit passt, wird nie als Update übernommen, sondern immer als neue Stunde behandelt. Kompetenzkatalog-Import (8.1.2): JSON-Export/Import für Kompetenzkataloge.
This commit is contained in:
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user