Files
LehrerApp/LehrerApp.Desktop/Services/AiPlanningService.cs
T
admin 1928916eac KI-Backend: Umfangs-Umschalter + Prompt Caching (4.5.15/4.5.16)
Umfangs-Umschalter: Checkbox "Auch bestehende Stundeninhalte anpassen" im
AiAssistDialog steuert, ob die KI bestehende Stunden inhaltlich ändern darf oder
die Einheit nur um neue Stunden erweitern soll. Zweifach durchgesetzt (Systemprompt
+ hartes client-seitiges Verwerfen in ApplyResponse), nicht nur der KI-Antwort
vertraut. Dabei auch einen Bug gefixt: eine bereits "Durchgeführt" markierte Stunde
wurde durch eine übernommene KI-Änderung stillschweigend auf "Geplant" zurückgesetzt.

Prompt Caching: der Systemprompt ist jetzt vollständig statisch (Voraussetzung für
Caching) und wird von AnthropicProvider.php als "cache_control: ephemeral" markiert
— wiederholte Anfragen innerhalb der 5-Minuten-TTL zahlen nur den reduzierten
Cache-Read-Preis. transactions-Tabelle und Preistabelle um Cache-Token-Spalten
erweitert, Migration für bereits deployte Installationen beigelegt.
2026-08-16 16:06:52 +02:00

228 lines
10 KiB
C#

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,
bool allowModifyingExistingLessons = true)
{
var request = new AiPlanningRequest
{
Instruction = instruction,
Unit = BuildContext(unit, instruction),
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; } }
}