Live-Test lieferte JsonException: DateOnly/TimeOnly hatten keinen Converter fürs im Systemprompt dokumentierte deutsche Format (TT.MM.JJJJ/HH:mm), .NET nutzte stattdessen ISO 8601 in beide Richtungen. Neue Converter mit Fallback aufs allgemeine Parsen. AiAssistDialog: indeterminierter ProgressBar statt nur Text während der Anfrage. Neuer Button "Erneut anfragen" erlaubt Nachfassen mit geänderter Anweisung, ohne den Dialog neu zu starten — schickt die aktuell angehakten Vorschläge als Entwurfskontext mit (AiPlanningService.MergeDraft), damit die KI auf dem noch ungespeicherten Stand aufbaut. Side-by-side-Vergleich beider Entwürfe als 4.5.18 zurückgestellt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
300 lines
14 KiB
C#
300 lines
14 KiB
C#
using LehrerApp.Core.AiPlanning;
|
|
using LehrerApp.Core.Interfaces;
|
|
using LehrerApp.Core.Models;
|
|
using System.Globalization;
|
|
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
|
|
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>
|
|
/// Der KI-Wire-Vertrag verwendet deutsches Datumsformat (siehe ai-backend/plan.php Systemprompt,
|
|
/// Abschnitt "Eingabeschema"/"Antwortformat"), nicht .NETs Standardformat für DateOnly (ISO
|
|
/// "yyyy-MM-dd") — ohne diesen Converter würde jede im dokumentierten Format zurückgegebene
|
|
/// KI-Antwort beim Deserialisieren mit einer JsonException scheitern. Fällt defensiv auf
|
|
/// allgemeines Parsen zurück, falls die KI sich nicht exakt ans Format hält, statt hart zu
|
|
/// scheitern — LLMs weichen erfahrungsgemäß gelegentlich vom dokumentierten Format ab.
|
|
/// </summary>
|
|
public class GermanDateOnlyJsonConverter : JsonConverter<DateOnly>
|
|
{
|
|
private const string Format = "dd.MM.yyyy";
|
|
|
|
public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
|
{
|
|
var value = reader.GetString() ?? "";
|
|
if (DateOnly.TryParseExact(value, Format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var exact))
|
|
return exact;
|
|
if (DateOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var fallback))
|
|
return fallback;
|
|
throw new JsonException($"Datum \"{value}\" konnte nicht gelesen werden (erwartet: {Format}).");
|
|
}
|
|
|
|
public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options) =>
|
|
writer.WriteStringValue(value.ToString(Format, CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
/// <summary>Analog zu <see cref="GermanDateOnlyJsonConverter"/>, für "HH:mm" statt ISO-Zeiten.</summary>
|
|
public class GermanTimeOnlyJsonConverter : JsonConverter<TimeOnly>
|
|
{
|
|
private const string Format = "HH:mm";
|
|
|
|
public override TimeOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
|
{
|
|
var value = reader.GetString() ?? "";
|
|
if (TimeOnly.TryParseExact(value, Format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var exact))
|
|
return exact;
|
|
if (TimeOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var fallback))
|
|
return fallback;
|
|
throw new JsonException($"Uhrzeit \"{value}\" konnte nicht gelesen werden (erwartet: {Format}).");
|
|
}
|
|
|
|
public override void Write(Utf8JsonWriter writer, TimeOnly value, JsonSerializerOptions options) =>
|
|
writer.WriteStringValue(value.ToString(Format, CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
/// <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,
|
|
Converters = { new GermanDateOnlyJsonConverter(), new GermanTimeOnlyJsonConverter() },
|
|
};
|
|
|
|
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>
|
|
/// <param name="draftOverrides">
|
|
/// Noch nicht gespeicherter Entwurf aus einer vorherigen Antwortrunde (Nachfassen mit
|
|
/// geänderter Anweisung, siehe <see cref="MergeDraft"/>) — überschreibt einzelne Lessons im
|
|
/// gesendeten Kontext, ohne dass dafür etwas in der Datenbank gespeichert werden muss.
|
|
/// </param>
|
|
public AiUnitContext BuildContext(Unit unit, string instruction, List<AiLesson>? draftOverrides = null)
|
|
{
|
|
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();
|
|
|
|
if (draftOverrides is not null)
|
|
unitLessons = MergeDraft(unitLessons, draftOverrides);
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ersetzt in <paramref name="saved"/> (dem tatsächlichen Datenbankstand) jede Lesson, deren Id
|
|
/// auch in <paramref name="draft"/> vorkommt, durch die Entwurfsversion, und hängt Entwürfe ohne
|
|
/// Id (neu vorgeschlagene Lessons) an. So sieht die KI beim Nachfassen den Stand inklusive der
|
|
/// zuvor vorgeschlagenen, aber noch nicht gespeicherten Änderungen — ohne dass dafür etwas in
|
|
/// der Datenbank landen muss, bevor der Nutzer "Übernehmen" klickt.
|
|
/// </summary>
|
|
private static List<AiLesson> MergeDraft(List<AiLesson> saved, List<AiLesson> draft)
|
|
{
|
|
var draftById = draft.Where(d => d.Id.HasValue).ToDictionary(d => d.Id!.Value);
|
|
var merged = saved.Select(l => l.Id.HasValue && draftById.TryGetValue(l.Id.Value, out var replacement)
|
|
? replacement : l).ToList();
|
|
merged.AddRange(draft.Where(d => !d.Id.HasValue));
|
|
return merged;
|
|
}
|
|
|
|
public async Task<AiPlanningResponse> RequestPlanAsync(Unit unit, string instruction, string token,
|
|
bool allowModifyingExistingLessons = true, List<AiLesson>? draftOverrides = null)
|
|
{
|
|
var request = new AiPlanningRequest
|
|
{
|
|
Instruction = instruction,
|
|
Unit = BuildContext(unit, instruction, draftOverrides),
|
|
AllowModifyingExistingLessons = allowModifyingExistingLessons,
|
|
};
|
|
|
|
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).
|
|
/// Ist <paramref name="allowModifyingExistingLessons"/> false, werden Änderungen an bestehenden
|
|
/// Lessons zusätzlich hart verworfen (nicht nur per Systemprompt an die KI erbeten) — die
|
|
/// Einschränkung wird also nicht blind der KI-Antwort überlassen.
|
|
/// </summary>
|
|
public List<Lesson> ApplyResponse(Unit unit, List<AiLesson> acceptedLessons, bool allowModifyingExistingLessons = true)
|
|
{
|
|
var pathIdsByName = altPaths.GetAll().ToDictionary(p => p.Name, p => p.Id);
|
|
var existingLessons = lessons.GetByUnit(unit.Id).ToDictionary(l => l.Id);
|
|
|
|
var result = new List<Lesson>();
|
|
foreach (var ai in acceptedLessons)
|
|
{
|
|
var isUpdate = ai.Id is { } id && existingLessons.ContainsKey(id);
|
|
if (isUpdate && !allowModifyingExistingLessons) continue;
|
|
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,
|
|
// Status bleibt bei einer Änderung erhalten — sonst würde eine bereits
|
|
// durchgeführte Stunde durch eine KI-Anpassung stillschweigend auf "Geplant"
|
|
// zurückgesetzt (die KI kennt/liefert diesen Status gar nicht).
|
|
Status = isUpdate ? existingLessons[ai.Id!.Value].Status : LessonStatus.Planned,
|
|
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; } }
|
|
}
|