Files
LehrerApp/LehrerApp.Desktop/Services/AiPlanningService.cs
T
adminandClaude Sonnet 5 7b883555bf feat: Chemikalien-Recherche über lokale RiSU-Stoffliste (kein KI-Aufruf mehr)
Ersetzt den ursprünglichen KI-Websuche-Ansatz (GESTIS scheiterte sowohl über
Websuche als auch über direkten API-Zugriff an fehlenden schulspezifischen
Tätigkeitsbeschränkungen) durch einen reinen lokalen Datenbank-Lookup gegen die
offizielle RiSU-Stoffliste (1764 Stoffe inkl. Tätigkeitsbeschränkungs-Codes,
Legende zur Abfragezeit übersetzt statt in die Datenbank gebacken). Keine
KI-Kosten, keine Drittanbieter-Abhängigkeit mehr für diese Funktion.

HazardSubstance.AgeRestriction zu ActivityRestriction umbenannt, da die
Tätigkeitsbeschränkung mehr abdeckt als reine Altersgrenzen. Details und
verworfene Zwischenstände in TODO.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 23:43:56 +02:00

546 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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() },
};
/// <summary>
/// Baut eine nutzergerichtete Fehlermeldung für eine fehlgeschlagene Backend-Antwort, die
/// weder 401 noch 402 war (die haben eigene, feste Meldungen). Liest den vom Backend
/// gesendeten Klartext-Grund (<c>ai_backend_fail</c> liefert immer <c>{"error": "..."}</c>)
/// mit, statt nur eine pauschale Meldung zu zeigen — ohne das war z.B. beim Fehlschlagen der
/// Websuche in substance.php (falscher Tool-Bezeichner, fehlender Beta-Header o.ä.) der
/// eigentliche Grund nirgends sichtbar, nur "Die Anfrage ist fehlgeschlagen".
/// </summary>
private static async Task<AiBackendException> BuildRequestFailedExceptionAsync(HttpResponseMessage resp)
{
string? backendReason = null;
try
{
var body = await resp.Content.ReadFromJsonAsync<BackendErrorResult>(JsonOptions);
backendReason = string.IsNullOrWhiteSpace(body?.Error) ? null : body!.Error;
}
catch { /* Antwortkörper war kein valides {"error": "..."}-JSON - Fallback unten greift. */ }
return new AiBackendException(backendReason is null
? "Die Anfrage an den KI-Dienst ist fehlgeschlagen."
: $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}");
}
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 = ToAiPhases(l.Phases, pathNames),
})
.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,
};
}
private static List<AiPhaseStep> ToAiPhases(List<LessonPhaseStep> phases, Dictionary<Guid, string> pathNames) =>
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();
private static bool PhasesEqual(List<AiPhaseStep> a, List<AiPhaseStep> b)
{
if (a.Count != b.Count) return false;
for (var i = 0; i < a.Count; i++)
{
var x = a[i]; var y = b[i];
if (x.Name != y.Name || x.DurationMinutes != y.DurationMinutes || x.Activity != y.Activity
|| x.Material != y.Material || x.Shorthand != y.Shorthand || x.AlternativePathName != y.AlternativePathName)
return false;
}
return true;
}
/// <summary>
/// Vergleicht eine bestehende Lesson mit dem KI-Vorschlag für dieselbe Id auf Feldebene und
/// beschreibt die Unterschiede in kurzen, menschenlesbaren Sätzen — Grundlage für die
/// Detailanzeige im AiAssistDialog (4.5.14 Planungsdiff), statt Änderungen nur pauschal als
/// "Geändert" zu markieren. Nur echte Unterschiede werden aufgeführt, Felder ohne Änderung
/// tauchen nicht auf. Rein (kein I/O außer dem bereits im Speicher gehaltenen Alternativpfad-
/// Katalog), daher ohne echtes Deployment testbar.
/// </summary>
public List<string> DescribeChanges(Lesson existing, AiLesson proposed)
{
var diffs = new List<string>();
if (!string.Equals(existing.Topic, proposed.Topic, StringComparison.Ordinal))
diffs.Add($"Thema: „{existing.Topic}“ → „{proposed.Topic}“");
if (proposed.Date is { } date && date != existing.Date)
diffs.Add($"Datum: {existing.Date:dd.MM.yyyy} → {date:dd.MM.yyyy}");
if (proposed.StartTime != existing.StartTime)
{
var oldText = existing.StartTime?.ToString("HH:mm") ?? "kein Beginn";
var newText = proposed.StartTime?.ToString("HH:mm") ?? "kein Beginn";
diffs.Add($"Beginn: {oldText} → {newText}");
}
if (proposed.LessonNumber != existing.LessonNumber)
diffs.Add($"Stundennummer: {existing.LessonNumber?.ToString() ?? ""} → {proposed.LessonNumber?.ToString() ?? ""}");
if (!string.Equals(existing.Homework, proposed.Homework, StringComparison.Ordinal))
diffs.Add("Hausaufgabe geändert");
if (!string.Equals(existing.Reflection, proposed.Reflection, StringComparison.Ordinal))
diffs.Add("Reflexion geändert");
var pathNames = altPaths.GetAll().ToDictionary(p => p.Id, p => p.Name);
var existingPhases = ToAiPhases(existing.Phases, pathNames);
if (!PhasesEqual(existingPhases, proposed.Phases))
{
diffs.Add(existingPhases.Count == proposed.Phases.Count
? "Verlaufsplan geändert"
: $"Verlaufsplan geändert ({existingPhases.Count} → {proposed.Phases.Count} Phase(n))");
}
return diffs;
}
/// <summary>
/// Baut aus Einheit/Stunde/Phase und dem KI-Medienvorschlag (<see cref="AiPhaseStep.MaterialSuggestion"/>)
/// einen vollständigen, eigenständigen Prompt zum Einfügen in eine separate Claude-Sitzung —
/// die Lehrkraft kann sich das eigentliche Material (Tafelbild, Arbeitsblatt, ...) dort selbst
/// erzeugen lassen, ohne dass das über das eigene (kostenpflichtige) KI-Backend laufen muss.
/// Nur der kurze Vorschlagstext kommt von der KI, der restliche Kontext wird rein lokal aus
/// bereits vorhandenen Daten zusammengesetzt. Rein, ohne I/O.
/// </summary>
public string BuildMaterialPrompt(Unit unit, AiLesson lesson, AiPhaseStep phase)
{
var group = groups.GetById(unit.GroupId);
var subject = group?.SubjectId is { } subjectId ? subjects.GetById(subjectId) : null;
var dateText = lesson.Date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "noch offen";
var lines = new List<string>
{
$"Ich unterrichte {subject?.Name ?? "unbekanntes Fach"}, Klassenstufe {group?.GradeLevel ?? 0}, Gruppe {group?.Name ?? "-"}.",
$"Einheit: „{unit.Title}“",
$"Stunde: „{lesson.Topic}“ (Datum: {dateText})",
$"Phase: „{phase.Name}“, {phase.DurationMinutes} Min., Tätigkeit: {phase.Activity}",
};
if (!string.IsNullOrWhiteSpace(phase.Material))
lines.Add($"Bisheriges Material laut Planung: {phase.Material}");
lines.Add("");
lines.Add("Empfehlung aus der Unterrichtsplanung, was das Material zeigen/enthalten sollte:");
lines.Add(phase.MaterialSuggestion ?? "");
lines.Add("");
lines.Add("Bitte erstelle mir auf dieser Grundlage ein passendes Material für diese Phase " +
"(z.B. Tafelbild, Arbeitsblatt oder Visualisierung).");
return string.Join("\n", lines);
}
/// <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, Guid? focusLessonId = null)
{
var request = new AiPlanningRequest
{
Instruction = instruction,
Unit = BuildContext(unit, instruction, draftOverrides),
AllowModifyingExistingLessons = allowModifyingExistingLessons,
FocusLessonId = focusLessonId,
};
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 await BuildRequestFailedExceptionAsync(resp);
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>
/// Fragt einen didaktischen Hintergrund zu einer bereits vorgeschlagenen/geplanten Lesson ab
/// (4.5.21 "Schattenfeld") — eigener Endpunkt (ai-backend/explain.php), damit das nur bei
/// tatsächlicher Nutzung abgerechnet wird statt bei jeder plan.php-Antwort mitgeneriert zu
/// werden. Ändert nichts an der Lesson, liefert nur erklärenden Text.
/// </summary>
public async Task<string> RequestExplanationAsync(Unit unit, AiLesson lesson, string token)
{
var request = new AiExplainRequest { Unit = BuildContext(unit, ""), Lesson = lesson };
using var req = new HttpRequestMessage(HttpMethod.Post, "explain.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 await BuildRequestFailedExceptionAsync(resp);
try
{
var result = await resp.Content.ReadFromJsonAsync<AiExplainResponse>(JsonOptions);
return result?.Explanation ?? 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>
/// Fragt einen Entwurf für eine Gefährdungsbeurteilung zu genau dieser Lesson ab (Nutzerwunsch
/// neben 4.2 "Anhänge je Stunde"): die KI erkennt aus Thema/Verlaufsplan das Experiment und
/// liefert Stoffe, Gefährdungen, Schutzmaßnahmen etc. als Entwurf zur Weiterbearbeitung im
/// Gefährdungsbeurteilungs-Assistenten. Ändert nichts an der Lesson selbst.
/// </summary>
public async Task<AiHazardAssessmentResponse> RequestHazardAssessmentDraftAsync(Unit unit, AiLesson lesson, string token)
{
var request = new AiHazardAssessmentRequest { Unit = BuildContext(unit, ""), Lesson = lesson };
using var req = new HttpRequestMessage(HttpMethod.Post, "gbu.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 await BuildRequestFailedExceptionAsync(resp);
try
{
var result = await resp.Content.ReadFromJsonAsync<AiHazardAssessmentResponse>(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>
/// Fragt sicherheitsrelevante Daten zu einer Chemikalie ab (Nutzerwunsch neben 4.2): eigener
/// Endpunkt <c>ai-backend/substance.php</c>, serverseitig dateibasiert gecacht (Name/CAS,
/// Ablauffrist je nach Einstufung) — wiederholte Anfragen für denselben Stoff verursachen
/// nach dem ersten Mal keine weiteren Kosten mehr. Kein Unit-/Guppen-Kontext nötig, nur der
/// eingetippte Stoffname.
/// </summary>
public async Task<AiSubstanceResearchResponse> RequestSubstanceResearchAsync(string substanceName, string token)
{
using var req = new HttpRequestMessage(HttpMethod.Post, "substance.php")
{
Content = JsonContent.Create(new { name = substanceName }, 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 await BuildRequestFailedExceptionAsync(resp);
try
{
var result = await resp.Content.ReadFromJsonAsync<AiSubstanceResearchResponse>(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. Ist
/// <paramref name="focusLessonId"/> gesetzt (Anfrage aus dem Editor einer einzelnen Stunde
/// heraus), wird ebenso hart jede Lesson mit abweichender oder fehlender Id verworfen.
/// </summary>
public List<Lesson> ApplyResponse(Unit unit, List<AiLesson> acceptedLessons,
bool allowModifyingExistingLessons = true, Guid? focusLessonId = null)
{
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)
{
if (focusLessonId is { } focus && ai.Id != focus) continue;
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; } }
private class BackendErrorResult { public string? Error { get; set; } }
}