Compare commits
5
Commits
e6c30b0bc7
...
bfe214ecf2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfe214ecf2 | ||
|
|
d979a84e7b | ||
|
|
3f8813df51 | ||
|
|
19aa302487 | ||
|
|
a4733c156b |
@@ -178,3 +178,54 @@ public class AiSubstanceResearchResponse
|
|||||||
public string ActivityRestriction { get; set; } = "";
|
public string ActivityRestriction { get; set; } = "";
|
||||||
public string Source { get; set; } = "";
|
public string Source { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire-Vertrag für den Fehlzeiten-Statusvorschlag (ai-backend/untis-status.php, Nutzer-Feedback
|
||||||
|
/// zum Untis-Hub: der aus WebUntis abgeleitete Zielstatus war über den Anzeigetext oft nicht
|
||||||
|
/// nachvollziehbar). Bewusst OHNE jeden Personenbezug - <see cref="AiUntisStatusRow.Id"/> ist eine
|
||||||
|
/// rein technische, für die KI bedeutungslose Kennung, über die der Desktop-Client die Antwort
|
||||||
|
/// zurückordnet; kein Name, keine Klasse, kein Datum verlässt damit die App. Mehrere Zeilen eines
|
||||||
|
/// Abgleichslaufs werden in einer Anfrage gebündelt statt je Zeile einzeln (Kosten/Latenz).
|
||||||
|
/// </summary>
|
||||||
|
public class AiUntisStatusRequest
|
||||||
|
{
|
||||||
|
public List<AiUntisStatusRow> Rows { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AiUntisStatusRow
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
public string ReasonText { get; set; } = "";
|
||||||
|
public int AbsentMinutes { get; set; }
|
||||||
|
/// True, wenn WebUntis für den Eintrag bereits ein Bearbeitungsdatum führt (unabhängig vom
|
||||||
|
/// tatsächlichen Datum, das aus Datenschutzgründen nicht mitgeschickt wird).
|
||||||
|
public bool HandledOn { get; set; }
|
||||||
|
/// Schulinterne Konvention: Klammerung der Entschuldigungsnummer bedeutet unentschuldigt, ohne
|
||||||
|
/// Klammern entschuldigt; null, wenn keine Nummer hinterlegt ist (siehe UntisDiffService-Analog
|
||||||
|
/// in WebUntisLessonAbsenceComparisonViewModel.MapStatus).
|
||||||
|
public bool? ExternKeyInParentheses { get; set; }
|
||||||
|
/// Bereits regelbasiert ermittelter Status (siehe MapStatus) - der Systemprompt bittet die KI,
|
||||||
|
/// nur bei eindeutigem Widerspruch im Freitext davon abzuweichen, statt bei Unsicherheit zu raten.
|
||||||
|
public string CurrentGuess { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enthält absichtlich GENAU eine Zeile je gesendeter <see cref="AiUntisStatusRow.Id"/> - der
|
||||||
|
/// Client verwirft die gesamte Antwort, wenn die zurückgegebene Id-Menge nicht exakt der
|
||||||
|
/// gesendeten entspricht (siehe AiPlanningService.RequestUntisStatusSuggestionsAsync), statt sich
|
||||||
|
/// auf die Reihenfolge zu verlassen. So bleibt eine Verwechslung zwischen Vorschlag und Zeile
|
||||||
|
/// strukturell ausgeschlossen, nicht nur im Regelfall vermieden.
|
||||||
|
/// </summary>
|
||||||
|
public class AiUntisStatusResponse
|
||||||
|
{
|
||||||
|
public List<AiUntisStatusSuggestion> Suggestions { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AiUntisStatusSuggestion
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
/// Einer von "Present"/"Late"/"LeftDuringClass"/"ExcusePending"/"Excused"/"Unexcused" (siehe
|
||||||
|
/// Systemprompt in ai-backend/untis-status.php) - wird client-seitig gegen genau diese Menge
|
||||||
|
/// geprüft, bevor er als AttendanceStatus interpretiert wird.
|
||||||
|
public string Status { get; set; } = "";
|
||||||
|
}
|
||||||
|
|||||||
@@ -285,6 +285,10 @@ public interface ISubjectRepository
|
|||||||
public interface ICompetencyDomainRepository
|
public interface ICompetencyDomainRepository
|
||||||
{
|
{
|
||||||
List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel);
|
List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel);
|
||||||
|
// Über alle Klassenstufen eines Fachs hinweg - für einen Überblick über den gesamten Katalog
|
||||||
|
// eines Fachs (z.B. MCP-Tool get_competency_catalog ohne gradeLevel-Filter), ohne dass der
|
||||||
|
// Aufrufer erst jede vorkommende Klassenstufe einzeln erraten/abfragen müsste.
|
||||||
|
List<CompetencyDomain> GetBySubject(Guid subjectId);
|
||||||
CompetencyDomain? GetById(Guid id);
|
CompetencyDomain? GetById(Guid id);
|
||||||
void Save(CompetencyDomain domain);
|
void Save(CompetencyDomain domain);
|
||||||
void Delete(Guid id);
|
void Delete(Guid id);
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ public class Lesson : IHasAttachments
|
|||||||
/// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird).
|
/// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird).
|
||||||
public bool HomeworkCheckDismissed { get; set; }
|
public bool HomeworkCheckDismissed { get; set; }
|
||||||
public string? Reflection { get; set; }
|
public string? Reflection { get; set; }
|
||||||
|
/// Kompetenzcodes (<see cref="CompetencyItem.Code"/>) aus dem Katalog, die dieser Stunde als
|
||||||
|
/// Ganzes zugeordnet sind — analog zu <see cref="Unit.Competencies"/>, aber auf Stundenebene statt
|
||||||
|
/// Einheitenebene. Bewusst noch keine Verknüpfung je einzelner <see cref="LessonPhaseStep"/>
|
||||||
|
/// (siehe TODO.md 4.5.8, weiterhin offen); diese Liste deckt nur die gröbere Zuordnung ab.
|
||||||
|
public List<string> Competencies { get; set; } = [];
|
||||||
public LessonStatus Status { get; set; } = LessonStatus.Planned;
|
public LessonStatus Status { get; set; } = LessonStatus.Planned;
|
||||||
/// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und
|
/// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und
|
||||||
/// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie
|
/// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie
|
||||||
@@ -103,7 +108,7 @@ public enum UnitStatus { Planned, Active, Completed }
|
|||||||
// Planned=0 und Conducted=1 bleiben absichtlich an ihren bisherigen numerischen Positionen:
|
// Planned=0 und Conducted=1 bleiben absichtlich an ihren bisherigen numerischen Positionen:
|
||||||
// LiteDB hat diese Werte bereits gespeichert. Die neuen Zustände werden nur angehängt, damit
|
// LiteDB hat diese Werte bereits gespeichert. Die neuen Zustände werden nur angehängt, damit
|
||||||
// vorhandene Daten ohne Migration weiterhin korrekt gelesen werden.
|
// vorhandene Daten ohne Migration weiterhin korrekt gelesen werden.
|
||||||
public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3 }
|
public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3, Cancelled = 4 }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
|
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public sealed class LessonSchedulingService(ILessonRepository lessons)
|
|||||||
{
|
{
|
||||||
foreach (var other in lessons.GetByUnit(lesson.UnitId))
|
foreach (var other in lessons.GetByUnit(lesson.UnitId))
|
||||||
{
|
{
|
||||||
if (other.Id == lesson.Id || other.Status == LessonStatus.Conducted || other.Date <= oldDate)
|
if (other.Id == lesson.Id || other.Status is LessonStatus.Conducted or LessonStatus.Cancelled || other.Date <= oldDate)
|
||||||
continue;
|
continue;
|
||||||
other.Date = other.Date.AddDays(delta);
|
other.Date = other.Date.AddDays(delta);
|
||||||
lessons.Save(other);
|
lessons.Save(other);
|
||||||
@@ -67,7 +67,7 @@ public sealed class LessonSchedulingService(ILessonRepository lessons)
|
|||||||
HomeworkChecked = source.HomeworkChecked,
|
HomeworkChecked = source.HomeworkChecked,
|
||||||
HomeworkCheckDismissed = source.HomeworkCheckDismissed,
|
HomeworkCheckDismissed = source.HomeworkCheckDismissed,
|
||||||
Reflection = source.Reflection,
|
Reflection = source.Reflection,
|
||||||
Status = source.Status == LessonStatus.Conducted ? LessonStatus.Draft : source.Status,
|
Status = source.Status is LessonStatus.Conducted or LessonStatus.Cancelled ? LessonStatus.Draft : source.Status,
|
||||||
};
|
};
|
||||||
source.Homework = null;
|
source.Homework = null;
|
||||||
source.HomeworkChecked = false;
|
source.HomeworkChecked = false;
|
||||||
|
|||||||
@@ -917,6 +917,11 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
|
|||||||
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
||||||
.OrderBy(d => d.SortOrder)
|
.OrderBy(d => d.SortOrder)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
public List<CompetencyDomain> GetBySubject(Guid subjectId) =>
|
||||||
|
db.CompetencyDomains
|
||||||
|
.Find(d => d.SubjectId == subjectId)
|
||||||
|
.OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder)
|
||||||
|
.ToList();
|
||||||
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
|
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
|
||||||
public void Save(CompetencyDomain d)
|
public void Save(CompetencyDomain d)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -116,6 +116,72 @@ public sealed class AiPlanningServiceTests
|
|||||||
Assert.Equal("Gerettete Stunde", Assert.Single(lessons.GetByUnit(unit.Id)).Topic);
|
Assert.Equal("Gerettete Stunde", Assert.Single(lessons.GetByUnit(unit.Id)).Topic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestUntisStatusSuggestionsAsync_PassendeIds_LiefertZuordnung()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
{"suggestions":[{"id":"0","status":"Excused"},{"id":"1","status":"Unexcused"}]}
|
||||||
|
""";
|
||||||
|
var service = BuildWithResponse(HttpStatusCode.OK, body);
|
||||||
|
var rows = new[]
|
||||||
|
{
|
||||||
|
new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" },
|
||||||
|
new AiUntisStatusRow { Id = "1", CurrentGuess = "ExcusePending" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token");
|
||||||
|
|
||||||
|
Assert.Equal("Excused", suggestions["0"]);
|
||||||
|
Assert.Equal("Unexcused", suggestions["1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kernabsicherung gegen Verwechslung (Nutzer-Feedback zum Untis-Hub, siehe
|
||||||
|
// AiPlanningService.RequestUntisStatusSuggestionsAsync): weicht die zurückgegebene Id-Menge
|
||||||
|
// auch nur minimal von der gesendeten ab (hier: eine erfundene Id "2" statt "1"), wird die
|
||||||
|
// gesamte Antwort verworfen statt sich auf eine möglicherweise vermischte Zuordnung zu verlassen.
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestUntisStatusSuggestionsAsync_AbweichendeIdMenge_VerwirftKomplett()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
{"suggestions":[{"id":"0","status":"Excused"},{"id":"2","status":"Unexcused"}]}
|
||||||
|
""";
|
||||||
|
var service = BuildWithResponse(HttpStatusCode.OK, body);
|
||||||
|
var rows = new[]
|
||||||
|
{
|
||||||
|
new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" },
|
||||||
|
new AiUntisStatusRow { Id = "1", CurrentGuess = "ExcusePending" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token");
|
||||||
|
|
||||||
|
Assert.Empty(suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestUntisStatusSuggestionsAsync_DoppelteId_VerwirftKomplett()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
{"suggestions":[{"id":"0","status":"Excused"},{"id":"0","status":"Unexcused"}]}
|
||||||
|
""";
|
||||||
|
var service = BuildWithResponse(HttpStatusCode.OK, body);
|
||||||
|
var rows = new[] { new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" } };
|
||||||
|
|
||||||
|
var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token");
|
||||||
|
|
||||||
|
Assert.Empty(suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestUntisStatusSuggestionsAsync_KeineZeilen_KeinNetzwerkaufruf()
|
||||||
|
{
|
||||||
|
var service = Build(new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||||
|
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||||
|
|
||||||
|
var suggestions = await service.RequestUntisStatusSuggestionsAsync([], "token");
|
||||||
|
|
||||||
|
Assert.Empty(suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
private static AiPlanningService BuildWithResponse(HttpStatusCode status, string body)
|
private static AiPlanningService BuildWithResponse(HttpStatusCode status, string body)
|
||||||
{
|
{
|
||||||
var http = new HttpClient(new StaticResponseHandler(status, body))
|
var http = new HttpClient(new StaticResponseHandler(status, body))
|
||||||
|
|||||||
@@ -373,9 +373,18 @@ public class FakeSubjects(List<Subject> all) : ISubjectRepository
|
|||||||
{
|
{
|
||||||
public List<Subject> GetAll() => all;
|
public List<Subject> GetAll() => all;
|
||||||
public Subject? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
|
public Subject? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
|
||||||
public Subject? GetByName(string name) => all.FirstOrDefault(s => s.Name == name);
|
public Subject? GetByName(string name) => all.FirstOrDefault(s =>
|
||||||
public void Save(Subject subject) { }
|
string.Equals(s.Name.Trim(), name.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||||
public void Delete(Guid id) { }
|
public void Save(Subject subject)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(subject.Name)) throw new ArgumentException("Der Fachname darf nicht leer sein.");
|
||||||
|
var duplicate = GetByName(subject.Name);
|
||||||
|
if (duplicate is not null && duplicate.Id != subject.Id)
|
||||||
|
throw new InvalidOperationException("Ein Fach mit diesem Namen existiert bereits.");
|
||||||
|
all.RemoveAll(s => s.Id == subject.Id);
|
||||||
|
all.Add(subject);
|
||||||
|
}
|
||||||
|
public void Delete(Guid id) => all.RemoveAll(s => s.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class FakeCompetencyDomains : ICompetencyDomainRepository
|
public class FakeCompetencyDomains : ICompetencyDomainRepository
|
||||||
@@ -385,6 +394,9 @@ public class FakeCompetencyDomains : ICompetencyDomainRepository
|
|||||||
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||||
_all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
_all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
||||||
.OrderBy(d => d.SortOrder).ToList();
|
.OrderBy(d => d.SortOrder).ToList();
|
||||||
|
public List<CompetencyDomain> GetBySubject(Guid subjectId) =>
|
||||||
|
_all.Where(d => d.SubjectId == subjectId)
|
||||||
|
.OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder).ToList();
|
||||||
public CompetencyDomain? GetById(Guid id) => _all.FirstOrDefault(d => d.Id == id);
|
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 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 Delete(Guid id) => _all.RemoveAll(d => d.Id == id);
|
||||||
@@ -610,11 +622,17 @@ public class FakeMcpConfirmation : IMcpConfirmationService
|
|||||||
public string? LastMessage { get; private set; }
|
public string? LastMessage { get; private set; }
|
||||||
public int CallCount { get; private set; }
|
public int CallCount { get; private set; }
|
||||||
|
|
||||||
public Task<bool> ConfirmAsync(string title, string message, CancellationToken ct)
|
public string? LastOperationKey { get; private set; }
|
||||||
|
public bool? LastAllowSessionTrust { get; private set; }
|
||||||
|
|
||||||
|
public Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
|
||||||
|
[System.Runtime.CompilerServices.CallerMemberName] string operationKey = "", bool allowSessionTrust = true)
|
||||||
{
|
{
|
||||||
CallCount++;
|
CallCount++;
|
||||||
LastTitle = title;
|
LastTitle = title;
|
||||||
LastMessage = message;
|
LastMessage = message;
|
||||||
|
LastOperationKey = operationKey;
|
||||||
|
LastAllowSessionTrust = allowSessionTrust;
|
||||||
return Task.FromResult(Response);
|
return Task.FromResult(Response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,39 @@ public sealed class LessonSchedulingServiceTests
|
|||||||
Assert.Equal(new DateOnly(2026, 9, 4), conducted.Date);
|
Assert.Equal(new DateOnly(2026, 9, 4), conducted.Date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Move_RuecktAuchAusgefalleneFolgestundenNichtNach()
|
||||||
|
{
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var moved = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 1) };
|
||||||
|
var cancelledFollowing = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 4), Status = LessonStatus.Cancelled };
|
||||||
|
var repo = new FakeLessons();
|
||||||
|
repo.Add(moved); repo.Add(cancelledFollowing);
|
||||||
|
|
||||||
|
new LessonSchedulingService(repo).Move(moved, new(2026, 9, 8), null, shiftFollowing: true);
|
||||||
|
|
||||||
|
Assert.Equal(new DateOnly(2026, 9, 4), cancelledFollowing.Date);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SplitAndMoveSecondPart_AusgefalleneQuellstundeSetztFortsetzungAufEntwurfZurueck()
|
||||||
|
{
|
||||||
|
var source = new Lesson
|
||||||
|
{
|
||||||
|
UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new(2026, 9, 1),
|
||||||
|
Status = LessonStatus.Cancelled,
|
||||||
|
Phases = [new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 60 }],
|
||||||
|
};
|
||||||
|
var repo = new FakeLessons();
|
||||||
|
repo.Add(source);
|
||||||
|
|
||||||
|
var continuation = new LessonSchedulingService(repo).SplitAndMoveSecondPart(source, 30,
|
||||||
|
new(2026, 9, 3), 1, null);
|
||||||
|
|
||||||
|
Assert.Equal(LessonStatus.Draft, continuation.Status);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase()
|
public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
public sealed class LessonStatusDisplayTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(LessonStatus.Draft, "Entwurf")]
|
||||||
|
[InlineData(LessonStatus.Ready, "Bereit")]
|
||||||
|
[InlineData(LessonStatus.Conducted, "Durchgeführt")]
|
||||||
|
[InlineData(LessonStatus.Cancelled, "Ausgefallen")]
|
||||||
|
[InlineData(LessonStatus.Planned, "Geplant")]
|
||||||
|
public void ToName_FromName_RoundTrip(LessonStatus status, string expectedName)
|
||||||
|
{
|
||||||
|
Assert.Equal(expectedName, LessonStatusDisplay.ToName(status));
|
||||||
|
Assert.Equal(status, LessonStatusDisplay.FromName(expectedName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnitSummary_AusgefalleneStundenZaehlenWederAlsGehaltenNochAlsPensum()
|
||||||
|
{
|
||||||
|
var unit = new Unit { Title = "Optik" };
|
||||||
|
var lessons = new List<Lesson>
|
||||||
|
{
|
||||||
|
new() { Status = LessonStatus.Conducted },
|
||||||
|
new() { Status = LessonStatus.Cancelled },
|
||||||
|
new() { Status = LessonStatus.Ready },
|
||||||
|
};
|
||||||
|
|
||||||
|
var summary = new UnitSummary(unit, lessons);
|
||||||
|
|
||||||
|
Assert.Equal(2, summary.TotalCount);
|
||||||
|
Assert.Equal(1, summary.ConductedCount);
|
||||||
|
Assert.Equal(0.5, summary.ProgressFraction);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,9 +15,10 @@ public sealed class McpToolsTests
|
|||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
"download_lesson_attachment", "get_exams", "get_grades", "get_lesson_plans",
|
"download_lesson_attachment", "get_competency_catalog", "get_exams", "get_grades",
|
||||||
"get_schedule", "get_students", "get_time_entries", "list_letter_templates",
|
"get_lesson_plans", "get_named_untis_absence_pattern", "get_schedule", "get_students",
|
||||||
"render_letter",
|
"get_subjects", "get_time_entries", "get_untis_absence_rows", "get_untis_hub_status",
|
||||||
|
"list_letter_templates", "render_letter",
|
||||||
},
|
},
|
||||||
McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
|
McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||||
}
|
}
|
||||||
@@ -28,13 +29,24 @@ public sealed class McpToolsTests
|
|||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
"add_lesson_attachment", "add_lesson_phase", "create_grade_entry", "create_lesson",
|
"add_competency_item", "add_lesson_attachment", "add_lesson_competency", "add_lesson_phase",
|
||||||
"create_time_entry", "create_unit", "remove_lesson_phase", "update_lesson",
|
"apply_untis_absence_status", "create_competency_domain", "create_grade_entry", "create_lesson",
|
||||||
"update_lesson_phase", "update_student_group_assignment", "update_unit",
|
"create_subject", "create_time_entry", "create_unit", "move_lesson", "remove_competency_item",
|
||||||
|
"remove_lesson_competency", "remove_lesson_phase", "update_competency_domain",
|
||||||
|
"update_competency_item", "update_lesson", "update_lesson_phase", "update_student_group_assignment",
|
||||||
|
"update_subject", "update_unit",
|
||||||
},
|
},
|
||||||
McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal));
|
McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllowedDestructiveWriteTools_EnthaeltDeleteLessonSubjectUndCompetencyDomain()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { "delete_competency_domain", "delete_lesson", "delete_subject" },
|
||||||
|
McpToolScope.AllowedDestructiveWriteTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AllowedTools_EnthaeltKeineDokumentationstypen()
|
public void AllowedTools_EnthaeltKeineDokumentationstypen()
|
||||||
{
|
{
|
||||||
@@ -334,6 +346,25 @@ public sealed class McpToolsTests
|
|||||||
Assert.Equal(result.Id, phase.Id);
|
Assert.Equal(result.Id, phase.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddLessonPhase_UebergibtEigenenMethodennamenAlsOperationKey()
|
||||||
|
{
|
||||||
|
// Belegt, dass IMcpConfirmationService.ConfirmAsync den [CallerMemberName]-Mechanismus
|
||||||
|
// tatsächlich nutzt (Grundlage für die Sitzungsfreigabe "diese Aktion nicht mehr
|
||||||
|
// nachfragen" in AvaloniaMcpConfirmationService) - ohne echtes UI testbar, weil der Name
|
||||||
|
// vom Compiler an der Aufrufstelle in AddLessonPhase eingesetzt wird, unabhängig von der
|
||||||
|
// konkreten IMcpConfirmationService-Implementierung.
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||||
|
|
||||||
|
await tool.AddLessonPhase(lesson.Id, "Einstieg", 10);
|
||||||
|
|
||||||
|
Assert.Equal(nameof(LessonPlanTools.AddLessonPhase), confirmation.LastOperationKey);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateLessonPhase_AendertNurAngegebeneFelder()
|
public async Task UpdateLessonPhase_AendertNurAngegebeneFelder()
|
||||||
{
|
{
|
||||||
@@ -463,6 +494,110 @@ public sealed class McpToolsTests
|
|||||||
Assert.Equal(0, confirmation.CallCount);
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveLesson_NutzerBestaetigt_VerschiebtDatum()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung", Date = new DateOnly(2026, 1, 5) };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||||
|
|
||||||
|
var result = await tool.MoveLesson(lesson.Id, new DateOnly(2026, 1, 12));
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Equal(new DateOnly(2026, 1, 12), lessons.GetById(lesson.Id)!.Date);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveLesson_ShiftFollowing_VerschiebtSpaetereStundenDerselbenEinheitMit()
|
||||||
|
{
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
var moved = new Lesson { UnitId = unitId, Topic = "Brechung", Date = new DateOnly(2026, 1, 5) };
|
||||||
|
var later = new Lesson { UnitId = unitId, Topic = "Reflexion", Date = new DateOnly(2026, 1, 7) };
|
||||||
|
var earlier = new Lesson { UnitId = unitId, Topic = "Einstieg", Date = new DateOnly(2026, 1, 1) };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(moved); lessons.Add(later); lessons.Add(earlier);
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||||
|
|
||||||
|
// moved: 5.1. -> 12.1. (Delta +7 Tage), later (7.1., danach) muss mitwandern, earlier (1.1., davor) nicht.
|
||||||
|
var result = await tool.MoveLesson(moved.Id, new DateOnly(2026, 1, 12), shiftFollowingLessons: true);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Equal(new DateOnly(2026, 1, 14), lessons.GetById(later.Id)!.Date);
|
||||||
|
Assert.Equal(new DateOnly(2026, 1, 1), lessons.GetById(earlier.Id)!.Date);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveLesson_ZielStundeBelegt_LiefertFehlerNachBestaetigung()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var moved = new Lesson { GroupId = groupId, Topic = "Brechung", Date = new DateOnly(2026, 1, 5), LessonNumber = 1 };
|
||||||
|
var occupying = new Lesson { GroupId = groupId, Topic = "Anderer Kurs", Date = new DateOnly(2026, 1, 12), LessonNumber = 1 };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(moved); lessons.Add(occupying);
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.MoveLesson(moved.Id, new DateOnly(2026, 1, 12), newPeriod: 1);
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Equal(1, confirmation.CallCount); // Bestätigung lief bereits, Konflikt zeigt sich erst beim Speichern.
|
||||||
|
Assert.Equal(new DateOnly(2026, 1, 5), lessons.GetById(moved.Id)!.Date); // unverändert
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MoveLesson_UnbekannteStunde_LiefertFehlerOhneNachfrage()
|
||||||
|
{
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = BuildLessonPlanTools(confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.MoveLesson(Guid.NewGuid(), new DateOnly(2026, 1, 12));
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteLesson_NutzerBestaetigt_LoeschtEndgueltig()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||||
|
|
||||||
|
var result = await tool.DeleteLesson(lesson.Id);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Null(lessons.GetById(lesson.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteLesson_NutzerLehntAb_BleibtErhalten()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var confirmation = new FakeMcpConfirmation { Response = false };
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.DeleteLesson(lesson.Id);
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.NotNull(lessons.GetById(lesson.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteLesson_UnbekannteStunde_LiefertFehlerOhneNachfrage()
|
||||||
|
{
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = BuildLessonPlanTools(confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.DeleteLesson(Guid.NewGuid());
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
// ── GroupMembershipTools ─────────────────────────────────────────────────────────────────
|
// ── GroupMembershipTools ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -510,4 +645,237 @@ public sealed class McpToolsTests
|
|||||||
Assert.False(result.Applied);
|
Assert.False(result.Applied);
|
||||||
Assert.Equal(0, confirmation.CallCount);
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── LessonPlanTools — Kompetenzzuordnung ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddLessonCompetency_NutzerBestaetigt_OrdnetCodeZu()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||||
|
|
||||||
|
var result = await tool.AddLessonCompetency(lesson.Id, "PH.9.1");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Contains("PH.9.1", lessons.GetById(lesson.Id)!.Competencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddLessonCompetency_BereitsZugeordnet_FragtNichtNochmalNach()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
lesson.Competencies.Add("PH.9.1");
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.AddLessonCompetency(lesson.Id, "PH.9.1");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
Assert.Single(lessons.GetById(lesson.Id)!.Competencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveLessonCompetency_NutzerBestaetigt_EntferntCode()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
lesson.Competencies.Add("PH.9.1");
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||||
|
|
||||||
|
var result = await tool.RemoveLessonCompetency(lesson.Id, "PH.9.1");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Empty(lessons.GetById(lesson.Id)!.Competencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveLessonCompetency_NichtZugeordnet_LiefertFehlerOhneNachfrage()
|
||||||
|
{
|
||||||
|
var lesson = new Lesson { Topic = "Brechung" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(lesson);
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.RemoveLessonCompetency(lesson.Id, "PH.9.1");
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CompetencyTools ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSubjects_LiefertAlleFaecher()
|
||||||
|
{
|
||||||
|
var subject = new Subject { Name = "Mathematik", ShortName = "Ma" };
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([subject]), new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var dto = Assert.Single(tool.GetSubjects());
|
||||||
|
|
||||||
|
Assert.Equal("Mathematik", dto.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateSubject_NutzerBestaetigt_SpeichertFach()
|
||||||
|
{
|
||||||
|
var subjects = new FakeSubjects([]);
|
||||||
|
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.CreateSubject("Mathematik", "Ma");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var subject = Assert.Single(subjects.GetAll());
|
||||||
|
Assert.Equal("Mathematik", subject.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateSubject_NameBereitsVergeben_LiefertFehler()
|
||||||
|
{
|
||||||
|
var subjects = new FakeSubjects([new Subject { Name = "Mathematik" }]);
|
||||||
|
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.CreateSubject("Mathematik");
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Single(subjects.GetAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateSubject_AendertNurAngegebeneFelder()
|
||||||
|
{
|
||||||
|
var subject = new Subject { Name = "Mathematik", ShortName = "Ma" };
|
||||||
|
var subjects = new FakeSubjects([subject]);
|
||||||
|
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.UpdateSubject(subject.Id, shortName: "M");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var updated = subjects.GetById(subject.Id)!;
|
||||||
|
Assert.Equal("Mathematik", updated.Name);
|
||||||
|
Assert.Equal("M", updated.ShortName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteSubject_NutzerBestaetigt_LoeschtFach()
|
||||||
|
{
|
||||||
|
var subject = new Subject { Name = "Mathematik" };
|
||||||
|
var subjects = new FakeSubjects([subject]);
|
||||||
|
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.DeleteSubject(subject.Id);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Empty(subjects.GetAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteSubject_UnbekannteId_LiefertFehlerOhneNachfrage()
|
||||||
|
{
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([]), new FakeCompetencyDomains(), confirmation);
|
||||||
|
|
||||||
|
var result = await tool.DeleteSubject(Guid.NewGuid());
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetCompetencyCatalog_OhneKlassenstufe_LiefertAlleKlassenstufenDesFachs()
|
||||||
|
{
|
||||||
|
var subjectId = Guid.NewGuid();
|
||||||
|
var domains = new FakeCompetencyDomains();
|
||||||
|
domains.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 5, Name = "Zahlen" });
|
||||||
|
domains.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 9, Name = "Funktionen" });
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = tool.GetCompetencyCatalog(subjectId);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateCompetencyDomain_NutzerBestaetigt_SpeichertBereich()
|
||||||
|
{
|
||||||
|
var subject = new Subject { Name = "Mathematik" };
|
||||||
|
var domains = new FakeCompetencyDomains();
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([subject]), domains, new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.CreateCompetencyDomain(subject.Id, 9, "Funktionen");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var domain = Assert.Single(domains.GetBySubjectAndGrade(subject.Id, 9));
|
||||||
|
Assert.Equal("Funktionen", domain.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteCompetencyDomain_NutzerBestaetigt_LoeschtBereichMitItems()
|
||||||
|
{
|
||||||
|
var domain = new CompetencyDomain { Name = "Funktionen" };
|
||||||
|
domain.Items.Add(new CompetencyItem { Code = "M.9.1", Description = "..." });
|
||||||
|
var domains = new FakeCompetencyDomains();
|
||||||
|
domains.Add(domain);
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.DeleteCompetencyDomain(domain.Id);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Null(domains.GetById(domain.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddCompetencyItem_NutzerBestaetigt_HaengtItemAn()
|
||||||
|
{
|
||||||
|
var domain = new CompetencyDomain { Name = "Funktionen" };
|
||||||
|
var domains = new FakeCompetencyDomains();
|
||||||
|
domains.Add(domain);
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.AddCompetencyItem(domain.Id, "M.9.1", "lineare Funktionen erkennen");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var item = Assert.Single(domains.GetById(domain.Id)!.Items);
|
||||||
|
Assert.Equal("M.9.1", item.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateCompetencyItem_AendertNurAngegebeneFelder()
|
||||||
|
{
|
||||||
|
var item = new CompetencyItem { Code = "M.9.1", Description = "alt" };
|
||||||
|
var domain = new CompetencyDomain { Name = "Funktionen" };
|
||||||
|
domain.Items.Add(item);
|
||||||
|
var domains = new FakeCompetencyDomains();
|
||||||
|
domains.Add(domain);
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.UpdateCompetencyItem(domain.Id, item.Id, description: "neu");
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var updated = domains.GetById(domain.Id)!.Items.Single();
|
||||||
|
Assert.Equal("M.9.1", updated.Code);
|
||||||
|
Assert.Equal("neu", updated.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveCompetencyItem_NutzerBestaetigt_EntferntItem()
|
||||||
|
{
|
||||||
|
var item = new CompetencyItem { Code = "M.9.1", Description = "..." };
|
||||||
|
var domain = new CompetencyDomain { Name = "Funktionen" };
|
||||||
|
domain.Items.Add(item);
|
||||||
|
var domains = new FakeCompetencyDomains();
|
||||||
|
domains.Add(domain);
|
||||||
|
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
var result = await tool.RemoveCompetencyItem(domain.Id, item.Id);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Empty(domains.GetById(domain.Id)!.Items);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
/// Aus Konsistenz mit den drei bestehenden WebUntis-Abgleichs-ViewModels bewusst ohne Tests für
|
||||||
|
/// GetUntisAbsenceRows/GetNamedUntisAbsencePattern gelassen (siehe TODO.md) - beide rufen
|
||||||
|
/// WebUntisIntegrationService.GetLessonAbsencesAsync auf, was einen echten WebUntis-JSON-RPC-
|
||||||
|
/// Handshake voraussetzt. Getestet werden die Teile, die keinen WebUntis-Zugriff brauchen:
|
||||||
|
/// GetUntisHubStatus (reine Delegation an UntisHubService) und die Validierungspfade von
|
||||||
|
/// ApplyUntisAbsenceStatus, die vor jedem WebUntis-/Datenbankzugriff greifen.
|
||||||
|
public sealed class UntisComparisonToolsTests
|
||||||
|
{
|
||||||
|
private static UntisComparisonTools Build(
|
||||||
|
List<LearningGroup>? groups = null, List<Student>? students = null,
|
||||||
|
List<ParticipationSession>? sessions = null, FakeMcpConfirmation? confirmation = null) =>
|
||||||
|
new(new FakeGroups(groups ?? []), new FakeStudents(students ?? []), new FakeSessions(sessions ?? []),
|
||||||
|
new FakeEntries(), TestSupport.BuildWebUntisIntegrationService(),
|
||||||
|
TestSupport.BuildUntisHubService(groups ?? []), confirmation ?? new FakeMcpConfirmation());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetUntisHubStatus_DelegiertAnUntisHubServiceUndMapptKindUndDueStateAlsText()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "9a", WebUntisLessonId = 42 };
|
||||||
|
var tool = Build([group]);
|
||||||
|
|
||||||
|
var rows = tool.GetUntisHubStatus();
|
||||||
|
|
||||||
|
Assert.Contains(rows, r => r.Kind == nameof(UntisHubJobKind.FehlzeitenKurz) && r.GroupName == "9a"
|
||||||
|
&& r.DueState == nameof(UntisHubDueState.Overdue));
|
||||||
|
Assert.Contains(rows, r => r.Kind == nameof(UntisHubJobKind.OffenePeriods) && r.GroupName == "Offene Stunden");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyUntisAbsenceStatus_UnbekannterStatus_WirdOhneRowIdPruefungAbgelehnt()
|
||||||
|
{
|
||||||
|
var tool = Build();
|
||||||
|
|
||||||
|
var result = await tool.ApplyUntisAbsenceStatus("irgendeine-id", "Suspendiert");
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Contains("Status", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyUntisAbsenceStatus_NichtSelektierbarerAberGueltigerEnumWert_WirdAbgelehnt()
|
||||||
|
{
|
||||||
|
// "Truant"/"Suspended" etc. sind gültige AttendanceStatus-Werte, aber keine, die WebUntis
|
||||||
|
// hier je melden würde (siehe SelectableStatuses) - müssen trotzdem abgelehnt werden, damit
|
||||||
|
// ein KI-Client nicht versehentlich einen fachlich unpassenden Status setzen kann.
|
||||||
|
var tool = Build();
|
||||||
|
|
||||||
|
var result = await tool.ApplyUntisAbsenceStatus("irgendeine-id", nameof(AttendanceStatus.Truant));
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyUntisAbsenceStatus_UnbekannteRowId_WirdAbgelehntOhneBestaetigung()
|
||||||
|
{
|
||||||
|
var confirmation = new FakeMcpConfirmation();
|
||||||
|
var tool = Build(confirmation: confirmation);
|
||||||
|
|
||||||
|
var result = await tool.ApplyUntisAbsenceStatus("nie-vergeben", nameof(AttendanceStatus.Excused));
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Contains("row-id", result.Message);
|
||||||
|
Assert.Equal(0, confirmation.CallCount); // erst gar nicht bis zur Bestätigung vorgedrungen
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -108,4 +109,34 @@ public sealed class UntisHubServiceTests
|
|||||||
|
|
||||||
Assert.Equal(2, rows.Count(r => r.GroupId != null));
|
Assert.Equal(2, rows.Count(r => r.GroupId != null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecordRun_FehlzeitenLang_SchliesstAuchFehlzeitenKurzDerselbenGruppeAb()
|
||||||
|
{
|
||||||
|
// Nutzer-Feedback: der Langzeit-Abgleich deckt das kurzfristige Zeitfenster als Teilmenge mit
|
||||||
|
// ab - ohne diese Kopplung bliebe die kurzfristige Kadenz trotz erledigtem Langzeit-Abgleich
|
||||||
|
// als fällig stehen.
|
||||||
|
var group = Group("9a", 42);
|
||||||
|
var jobStates = new FakeUntisHubJobStates();
|
||||||
|
var hub = new UntisHubService(new FakeGroups([group]), jobStates, new SchoolYearService());
|
||||||
|
|
||||||
|
hub.RecordRun(UntisHubJobKind.FehlzeitenLang, group.Id, "3 Fehlzeiten übernommen");
|
||||||
|
|
||||||
|
var kurzState = jobStates.Get(UntisHubJobKind.FehlzeitenKurz, group.Id);
|
||||||
|
var langState = jobStates.Get(UntisHubJobKind.FehlzeitenLang, group.Id);
|
||||||
|
Assert.NotNull(kurzState?.LastRunAt);
|
||||||
|
Assert.Equal(langState!.LastRunAt, kurzState!.LastRunAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecordRun_FehlzeitenKurz_LaesstFehlzeitenLangUnangetastet()
|
||||||
|
{
|
||||||
|
var group = Group("9a", 42);
|
||||||
|
var jobStates = new FakeUntisHubJobStates();
|
||||||
|
var hub = new UntisHubService(new FakeGroups([group]), jobStates, new SchoolYearService());
|
||||||
|
|
||||||
|
hub.RecordRun(UntisHubJobKind.FehlzeitenKurz, group.Id, "keine Abweichungen");
|
||||||
|
|
||||||
|
Assert.Null(jobStates.Get(UntisHubJobKind.FehlzeitenLang, group.Id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,6 +226,8 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton<LessonPlanTools>();
|
services.AddSingleton<LessonPlanTools>();
|
||||||
services.AddSingleton<GroupMembershipTools>();
|
services.AddSingleton<GroupMembershipTools>();
|
||||||
services.AddSingleton<LetterTemplateTools>();
|
services.AddSingleton<LetterTemplateTools>();
|
||||||
|
services.AddSingleton<CompetencyTools>();
|
||||||
|
services.AddSingleton<UntisComparisonTools>();
|
||||||
services.AddSingleton<McpServerHostedService>();
|
services.AddSingleton<McpServerHostedService>();
|
||||||
services.AddSingleton<McpClientRegistrationService>();
|
services.AddSingleton<McpClientRegistrationService>();
|
||||||
|
|
||||||
|
|||||||
@@ -605,6 +605,59 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fragt für eine Charge anonymisierter Fehlzeitenzeilen (ai-backend/untis-status.php,
|
||||||
|
/// Nutzer-Feedback zum Untis-Hub) einen Statusvorschlag ab - eine Anfrage für alle fraglichen
|
||||||
|
/// Zeilen eines Abgleichslaufs statt einer je Zeile (Kosten/Latenz-Überlegung aus der
|
||||||
|
/// Nutzerdiskussion). Liefert nur dann Vorschläge zurück, wenn die vom Backend gemeldete
|
||||||
|
/// Id-Menge exakt der gesendeten entspricht (keine fehlenden, zusätzlichen oder doppelten Ids) -
|
||||||
|
/// andernfalls eine leere Zuordnung, statt sich auf eine möglicherweise vermischte Reihenfolge
|
||||||
|
/// zu verlassen. Der Aufrufer behält für jede nicht zurückgelieferte Id den bisherigen
|
||||||
|
/// regelbasierten Status bei.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyDictionary<string, string>> RequestUntisStatusSuggestionsAsync(
|
||||||
|
IReadOnlyList<AiUntisStatusRow> rows, string token)
|
||||||
|
{
|
||||||
|
if (rows.Count == 0) return new Dictionary<string, string>();
|
||||||
|
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Post, "untis-status.php")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new AiUntisStatusRequest { Rows = rows.ToList() }, 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);
|
||||||
|
|
||||||
|
AiUntisStatusResponse? result;
|
||||||
|
try { result = await resp.Content.ReadFromJsonAsync<AiUntisStatusResponse>(JsonOptions); }
|
||||||
|
catch (Exception ex) when (ex is not AiBackendException)
|
||||||
|
{
|
||||||
|
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
|
||||||
|
}
|
||||||
|
if (result is null)
|
||||||
|
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
|
||||||
|
|
||||||
|
var sentIds = rows.Select(r => r.Id).ToHashSet();
|
||||||
|
var receivedIds = result.Suggestions.Select(s => s.Id).ToList();
|
||||||
|
if (receivedIds.Count != sentIds.Count || receivedIds.Distinct().Count() != receivedIds.Count
|
||||||
|
|| !sentIds.SetEquals(receivedIds))
|
||||||
|
return new Dictionary<string, string>();
|
||||||
|
|
||||||
|
return result.Suggestions.ToDictionary(s => s.Id, s => s.Status);
|
||||||
|
}
|
||||||
|
|
||||||
private class LoginResult { public string Token { get; set; } = ""; }
|
private class LoginResult { public string Token { get; set; } = ""; }
|
||||||
private class BalanceResult { public decimal BalanceUsd { get; set; } }
|
private class BalanceResult { public decimal BalanceUsd { get; set; } }
|
||||||
private class BackendErrorResult
|
private class BackendErrorResult
|
||||||
|
|||||||
@@ -1,27 +1,59 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using LehrerApp.Desktop.Views.Shared;
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.Views.Mcp;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Services.Mcp;
|
namespace LehrerApp.Desktop.Services.Mcp;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Produktive <see cref="IMcpConfirmationService"/>-Implementierung: zeigt den bestehenden
|
/// Produktive <see cref="IMcpConfirmationService"/>-Implementierung: zeigt <see cref="McpConfirmDialog"/>
|
||||||
/// <see cref="ConfirmDialog"/> (Views/Shared) über dem Hauptfenster an. Der aufrufende Tool-Handler
|
/// über dem Hauptfenster an. Der aufrufende Tool-Handler läuft auf einem Hintergrund-Thread
|
||||||
/// läuft auf einem Hintergrund-Thread (MCP-Pipe-Session in <see cref="McpServerHostedService"/>),
|
/// (MCP-Pipe-Session in <see cref="McpServerHostedService"/>), deshalb Marshalling über
|
||||||
/// deshalb Marshalling über <see cref="Dispatcher.UIThread"/>.
|
/// <see cref="Dispatcher.UIThread"/>.
|
||||||
|
///
|
||||||
|
/// Nutzer-Feedback: der Dialog fiel zu wenig auf, wenn LehrerApp im Hintergrund lief (naheliegend,
|
||||||
|
/// da der Anstoß von einem KI-Client in einem anderen Fenster kommt) — deshalb wird das Hauptfenster
|
||||||
|
/// vor dem Anzeigen aus einer möglichen Minimierung geholt und aktiviert, und der Dialog selbst
|
||||||
|
/// läuft `Topmost`.
|
||||||
|
///
|
||||||
|
/// Nutzer-Feedback (Nachtrag): bei vielen gleichartigen Vorschlägen in Folge (z.B. 17x
|
||||||
|
/// "add_lesson_phase" für eine neu generierte Unterrichtseinheit) einzeln nachfragen zu müssen, ist
|
||||||
|
/// unzumutbar. Der Dialog bietet deshalb zwei Sitzungsfreigaben an ("diese Aktion" / "alle
|
||||||
|
/// Aktionen"), die als reines In-Memory-Bookkeeping auf dieser Singleton-Instanz leben — sie gelten
|
||||||
|
/// bis zum Beenden der App (neuer Prozess = neue Instanz = wieder alles ungetraut) und werden nie
|
||||||
|
/// persistiert. <paramref name="operationKey"/> ist der Name der aufrufenden Tool-Methode (siehe
|
||||||
|
/// <see cref="IMcpConfirmationService.ConfirmAsync"/>), nicht der MCP-Wire-Name.
|
||||||
///
|
///
|
||||||
/// Ohne Reaktion des Nutzers würde die Pipe-Session (und damit der wartende KI-Client) unbegrenzt
|
/// Ohne Reaktion des Nutzers würde die Pipe-Session (und damit der wartende KI-Client) unbegrenzt
|
||||||
/// hängen bleiben — nach <see cref="Timeout"/> wird der Dialog automatisch geschlossen und die
|
/// hängen bleiben — nach <see cref="Timeout"/> wird der Dialog automatisch geschlossen und die
|
||||||
/// Änderung als abgelehnt gewertet.
|
/// Änderung als abgelehnt gewertet.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService
|
public sealed class AvaloniaMcpConfirmationService(AppLogger logger) : IMcpConfirmationService
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2);
|
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
public async Task<bool> ConfirmAsync(string title, string message, CancellationToken ct)
|
private readonly object _trustLock = new();
|
||||||
|
private readonly HashSet<string> _trustedOperations = [];
|
||||||
|
private bool _trustAll;
|
||||||
|
|
||||||
|
public async Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
|
||||||
|
[CallerMemberName] string operationKey = "", bool allowSessionTrust = true)
|
||||||
{
|
{
|
||||||
|
if (allowSessionTrust)
|
||||||
|
{
|
||||||
|
bool alreadyTrusted;
|
||||||
|
lock (_trustLock) alreadyTrusted = _trustAll || _trustedOperations.Contains(operationKey);
|
||||||
|
if (alreadyTrusted)
|
||||||
|
{
|
||||||
|
logger.Info($"MCP: „{title}“ automatisch bestätigt (Sitzungsfreigabe für " +
|
||||||
|
$"{(_trustAll ? "alle Aktionen" : operationKey)}).");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
|
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -29,21 +61,38 @@ public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService
|
|||||||
// Avalonias Dispatcher-Synchronisationskontext sorgt dafür, dass die Fortsetzung nach
|
// Avalonias Dispatcher-Synchronisationskontext sorgt dafür, dass die Fortsetzung nach
|
||||||
// "await Task.WhenAny(...)" wieder auf dem UI-Thread läuft, sodass dialog.Close() dort
|
// "await Task.WhenAny(...)" wieder auf dem UI-Thread läuft, sodass dialog.Close() dort
|
||||||
// sicher aufgerufen werden kann.
|
// sicher aufgerufen werden kann.
|
||||||
return await Dispatcher.UIThread.InvokeAsync(async () =>
|
var result = await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||||
{
|
{
|
||||||
var dialog = new ConfirmDialog
|
if (owner.WindowState == WindowState.Minimized) owner.WindowState = WindowState.Normal;
|
||||||
|
owner.Activate();
|
||||||
|
|
||||||
|
var dialog = new McpConfirmDialog
|
||||||
{
|
{
|
||||||
DataContext = new ConfirmDialogInfo { Title = title, Message = message, ConfirmText = "Übernehmen" },
|
DataContext = new McpConfirmDialogInfo
|
||||||
|
{
|
||||||
|
Title = title, Message = message, ConfirmText = "Übernehmen",
|
||||||
|
AllowSessionTrust = allowSessionTrust,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
var dialogTask = dialog.ShowDialog<bool>(owner);
|
var dialogTask = dialog.ShowDialog<McpConfirmDialogResult>(owner);
|
||||||
var timeoutTask = Task.Delay(Timeout, ct);
|
var timeoutTask = Task.Delay(Timeout, ct);
|
||||||
var completed = await Task.WhenAny(dialogTask, timeoutTask);
|
var completed = await Task.WhenAny(dialogTask, timeoutTask);
|
||||||
if (completed != dialogTask)
|
if (completed != dialogTask)
|
||||||
{
|
{
|
||||||
dialog.Close(false);
|
dialog.Close(McpConfirmDialogResult.Rejected);
|
||||||
return false;
|
return McpConfirmDialogResult.Rejected;
|
||||||
}
|
}
|
||||||
return await dialogTask;
|
return await dialogTask;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.Approved && allowSessionTrust)
|
||||||
|
{
|
||||||
|
lock (_trustLock)
|
||||||
|
{
|
||||||
|
if (result.TrustAll) _trustAll = true;
|
||||||
|
else if (result.TrustOperation) _trustedOperations.Add(operationKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.Approved;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Services.Mcp;
|
namespace LehrerApp.Desktop.Services.Mcp;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -9,7 +11,20 @@ namespace LehrerApp.Desktop.Services.Mcp;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IMcpConfirmationService
|
public interface IMcpConfirmationService
|
||||||
{
|
{
|
||||||
/// <returns>true, wenn der Nutzer bestätigt hat; false bei Ablehnung, Timeout oder falls kein
|
/// <param name="operationKey">Identifiziert die Art der Operation für eine mögliche
|
||||||
/// Hauptfenster verfügbar ist (z.B. während des DB-Passwort-Prompts beim Start).</returns>
|
/// Sitzungsfreigabe ("diese Aktion für den Rest der Sitzung nicht mehr nachfragen") — bewusst
|
||||||
Task<bool> ConfirmAsync(string title, string message, CancellationToken ct);
|
/// per <see cref="CallerMemberNameAttribute"/> automatisch befüllt (der Name der aufrufenden
|
||||||
|
/// Tool-Methode, z.B. "AddLessonPhase"), damit kein Aufrufer diesen Parameter selbst pflegen
|
||||||
|
/// muss. Nicht Teil des MCP-Wire-Protokolls, rein internes Bestätigungs-Bookkeeping.</param>
|
||||||
|
/// <param name="allowSessionTrust">False für die eine bewusste Ausnahme, bei der eine
|
||||||
|
/// Sitzungsfreigabe nicht angeboten werden soll (Nutzer-Entscheidung zu
|
||||||
|
/// <c>get_named_untis_absence_pattern</c>: eine namentliche Fehlzeitenauskunft muss JEDES MAL
|
||||||
|
/// einzeln bestätigt werden, nie pauschal für die restliche Sitzung) — weder die vorherige
|
||||||
|
/// Prüfung auf eine bereits bestehende Freigabe noch das Setzen einer neuen finden dann statt,
|
||||||
|
/// unabhängig davon, ob zuvor schon "alle Aktionen" freigegeben wurde.</param>
|
||||||
|
/// <returns>true, wenn der Nutzer bestätigt hat (direkt oder über eine bereits erteilte
|
||||||
|
/// Sitzungsfreigabe); false bei Ablehnung, Timeout oder falls kein Hauptfenster verfügbar ist
|
||||||
|
/// (z.B. während des DB-Passwort-Prompts beim Start).</returns>
|
||||||
|
Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
|
||||||
|
[CallerMemberName] string operationKey = "", bool allowSessionTrust = true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using ModelContextProtocol.Server;
|
|||||||
namespace LehrerApp.Desktop.Services.Mcp;
|
namespace LehrerApp.Desktop.Services.Mcp;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// In-Process-MCP-Server (Phase 1–3, siehe Planungsdokument). Lauscht auf der Named Pipe
|
/// In-Process-MCP-Server (siehe Planungsdokument und TODO.md 4.5.25ff.). Lauscht auf der Named Pipe
|
||||||
/// <see cref="McpPipeConstants.PipeName"/> und bedient jede eingehende Verbindung (eine je
|
/// <see cref="McpPipeConstants.PipeName"/> und bedient jede eingehende Verbindung (eine je
|
||||||
/// LehrerApp.McpBridge-Instanz) als eigene MCP-Session über <see cref="StreamServerTransport"/> —
|
/// LehrerApp.McpBridge-Instanz) als eigene MCP-Session über <see cref="StreamServerTransport"/> —
|
||||||
/// ein <see cref="NamedPipeServerStream"/> ist ein normaler <see cref="Stream"/> und kann direkt
|
/// ein <see cref="NamedPipeServerStream"/> ist ein normaler <see cref="Stream"/> und kann direkt
|
||||||
@@ -31,13 +31,14 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
|||||||
McpSettingsService settings, AppLogger logger,
|
McpSettingsService settings, AppLogger logger,
|
||||||
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
||||||
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
||||||
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools)
|
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools,
|
||||||
|
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools)
|
||||||
{
|
{
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_serverOptions = BuildServerOptions(
|
_serverOptions = BuildServerOptions(
|
||||||
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
|
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
|
||||||
groupMembershipTools, letterTemplateTools);
|
groupMembershipTools, letterTemplateTools, competencyTools, untisComparisonTools);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls
|
/// <summary>Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls
|
||||||
@@ -113,7 +114,8 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
|||||||
private static McpServerOptions BuildServerOptions(
|
private static McpServerOptions BuildServerOptions(
|
||||||
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
||||||
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
||||||
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools)
|
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools,
|
||||||
|
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools)
|
||||||
{
|
{
|
||||||
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
|
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||||
|
|
||||||
@@ -129,7 +131,8 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
|||||||
|
|
||||||
// Write-Tools schreiben nie direkt - jede Handler-Methode ruft selbst erst
|
// Write-Tools schreiben nie direkt - jede Handler-Methode ruft selbst erst
|
||||||
// IMcpConfirmationService auf (siehe die jeweilige Tool-Klasse). ReadOnly bewusst false,
|
// IMcpConfirmationService auf (siehe die jeweilige Tool-Klasse). ReadOnly bewusst false,
|
||||||
// Destructive bewusst false (keine der Phase-2-Schreiboperationen löscht etwas).
|
// Destructive bewusst false (keine dieser Schreiboperationen löscht etwas) - für die
|
||||||
|
// Ausnahme siehe AddDestructiveWriteTool/McpToolScope.AllowedDestructiveWriteTools.
|
||||||
void AddWriteTool(Delegate handler, string name, string description)
|
void AddWriteTool(Delegate handler, string name, string description)
|
||||||
{
|
{
|
||||||
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
||||||
@@ -141,6 +144,17 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void AddDestructiveWriteTool(Delegate handler, string name, string description)
|
||||||
|
{
|
||||||
|
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Description = description,
|
||||||
|
ReadOnly = false,
|
||||||
|
Destructive = true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
AddReadTool(studentTools.GetStudents, "get_students",
|
AddReadTool(studentTools.GetStudents, "get_students",
|
||||||
"Listet Schüler, optional gefiltert nach Lerngruppe.");
|
"Listet Schüler, optional gefiltert nach Lerngruppe.");
|
||||||
AddReadTool(examTools.GetExams, "get_exams",
|
AddReadTool(examTools.GetExams, "get_exams",
|
||||||
@@ -159,6 +173,16 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
|||||||
"Listet importierte Elternbrief-Vorlagen mit ihren Platzhaltern.");
|
"Listet importierte Elternbrief-Vorlagen mit ihren Platzhaltern.");
|
||||||
AddReadTool(letterTemplateTools.RenderLetter, "render_letter",
|
AddReadTool(letterTemplateTools.RenderLetter, "render_letter",
|
||||||
"Erzeugt einen Elternbrief aus einer Vorlage für einen Schüler als Base64-PDF.");
|
"Erzeugt einen Elternbrief aus einer Vorlage für einen Schüler als Base64-PDF.");
|
||||||
|
AddReadTool(competencyTools.GetSubjects, "get_subjects",
|
||||||
|
"Listet alle Fächer.");
|
||||||
|
AddReadTool(competencyTools.GetCompetencyCatalog, "get_competency_catalog",
|
||||||
|
"Listet den Kompetenzkatalog eines Fachs, optional gefiltert auf eine Klassenstufe.");
|
||||||
|
AddReadTool(untisComparisonTools.GetUntisHubStatus, "get_untis_hub_status",
|
||||||
|
"Listet die Fälligkeit der Untis-Hub-Abgleiche, ohne selbst WebUntis anzufragen.");
|
||||||
|
AddReadTool(untisComparisonTools.GetUntisAbsenceRows, "get_untis_absence_rows",
|
||||||
|
"Listet anonymisierte Fehlzeiten-Diskrepanzen einer Lerngruppe gegenüber WebUntis (keine Schülernamen, nur eine row-id je Zeile).");
|
||||||
|
AddReadTool(untisComparisonTools.GetNamedUntisAbsencePattern, "get_named_untis_absence_pattern",
|
||||||
|
"Liefert Fehlzeiten MIT Schülername für explizit angegebene Schüler-IDs - Ausnahme von der sonstigen Anonymisierung, erfordert jedes Mal eine gesonderte Nutzerbestätigung.");
|
||||||
|
|
||||||
AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry",
|
AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry",
|
||||||
"Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig).");
|
"Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig).");
|
||||||
@@ -182,10 +206,40 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
|||||||
"Entfernt eine Verlaufsplan-Phase aus einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
|
"Entfernt eine Verlaufsplan-Phase aus einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
|
||||||
AddWriteTool(lessonPlanTools.AddLessonAttachment, "add_lesson_attachment",
|
AddWriteTool(lessonPlanTools.AddLessonAttachment, "add_lesson_attachment",
|
||||||
"Fügt einer Einzelstunde ein neues Material als Base64-kodierten Anhang hinzu (Bestätigung durch den Nutzer nötig).");
|
"Fügt einer Einzelstunde ein neues Material als Base64-kodierten Anhang hinzu (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(lessonPlanTools.MoveLesson, "move_lesson",
|
||||||
|
"Verschiebt eine Einzelstunde auf ein neues Datum, optional mit Mitverschieben späterer Stunden derselben Einheit (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(lessonPlanTools.AddLessonCompetency, "add_lesson_competency",
|
||||||
|
"Ordnet einer Einzelstunde einen Kompetenzcode zu (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(lessonPlanTools.RemoveLessonCompetency, "remove_lesson_competency",
|
||||||
|
"Entfernt einen Kompetenzcode von einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.CreateSubject, "create_subject",
|
||||||
|
"Legt ein neues Fach an (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.UpdateSubject, "update_subject",
|
||||||
|
"Ändert Name/Kurzform eines Fachs (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.CreateCompetencyDomain, "create_competency_domain",
|
||||||
|
"Legt einen neuen Kompetenzbereich für ein Fach/eine Klassenstufe an (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.UpdateCompetencyDomain, "update_competency_domain",
|
||||||
|
"Ändert Name/Kürzel eines Kompetenzbereichs (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.AddCompetencyItem, "add_competency_item",
|
||||||
|
"Fügt einem Kompetenzbereich eine Einzelkompetenz hinzu (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.UpdateCompetencyItem, "update_competency_item",
|
||||||
|
"Ändert Code/Beschreibung einer Einzelkompetenz (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(competencyTools.RemoveCompetencyItem, "remove_competency_item",
|
||||||
|
"Entfernt eine Einzelkompetenz aus einem Kompetenzbereich (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(untisComparisonTools.ApplyUntisAbsenceStatus, "apply_untis_absence_status",
|
||||||
|
"Übernimmt einen Statusvorschlag für eine über get_untis_absence_rows gelieferte row-id (Bestätigung durch den Nutzer nötig, nennt keinen Schülernamen).");
|
||||||
|
|
||||||
|
AddDestructiveWriteTool(lessonPlanTools.DeleteLesson, "delete_lesson",
|
||||||
|
"Löscht eine Einzelstunde endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddDestructiveWriteTool(competencyTools.DeleteSubject, "delete_subject",
|
||||||
|
"Löscht ein Fach endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddDestructiveWriteTool(competencyTools.DeleteCompetencyDomain, "delete_competency_domain",
|
||||||
|
"Löscht einen Kompetenzbereich endgültig samt Einzelkompetenzen, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
|
||||||
|
|
||||||
System.Diagnostics.Debug.Assert(
|
System.Diagnostics.Debug.Assert(
|
||||||
toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n)
|
toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n)
|
||||||
.SequenceEqual(McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools).OrderBy(n => n)),
|
.SequenceEqual(McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools)
|
||||||
|
.Concat(McpToolScope.AllowedDestructiveWriteTools).OrderBy(n => n)),
|
||||||
"Registrierte MCP-Tools weichen von McpToolScope ab.");
|
"Registrierte MCP-Tools weichen von McpToolScope ab.");
|
||||||
|
|
||||||
return new McpServerOptions
|
return new McpServerOptions
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ namespace LehrerApp.Desktop.Services.Mcp;
|
|||||||
/// Tool-Klasse referenziert — ein KI-Client kann diese Daten technisch nicht erreichen, unabhängig
|
/// Tool-Klasse referenziert — ein KI-Client kann diese Daten technisch nicht erreichen, unabhängig
|
||||||
/// davon, wie vertrauenswürdig der lokale Modell-Client erscheint oder wie die Tool-Liste künftig
|
/// davon, wie vertrauenswürdig der lokale Modell-Client erscheint oder wie die Tool-Liste künftig
|
||||||
/// wächst. <see cref="McpServerHostedService"/> registriert nur exakt diese Namen.
|
/// wächst. <see cref="McpServerHostedService"/> registriert nur exakt diese Namen.
|
||||||
|
///
|
||||||
|
/// "get_named_untis_absence_pattern" ist die eine bewusste, eng begrenzte Ausnahme von der oben
|
||||||
|
/// beschriebenen Regel (Nutzer-Entscheidung, siehe TODO.md): es verknüpft Schülername mit
|
||||||
|
/// Fehlzeitendaten, aber nur für explizit angegebene Schüler-IDs und nur nach jedes Mal gesonderter,
|
||||||
|
/// prominenter Bestätigung ohne Sitzungsfreigabe (siehe UntisComparisonTools). Documentation/
|
||||||
|
/// Vorgang bleibt davon unberührt weiterhin vollständig ausgeschlossen.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class McpToolScope
|
public static class McpToolScope
|
||||||
{
|
{
|
||||||
@@ -21,6 +27,11 @@ public static class McpToolScope
|
|||||||
"download_lesson_attachment",
|
"download_lesson_attachment",
|
||||||
"list_letter_templates",
|
"list_letter_templates",
|
||||||
"render_letter",
|
"render_letter",
|
||||||
|
"get_subjects",
|
||||||
|
"get_competency_catalog",
|
||||||
|
"get_untis_hub_status",
|
||||||
|
"get_untis_absence_rows",
|
||||||
|
"get_named_untis_absence_pattern",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// <summary>Write-Tools (Phase 2+3) — jeder Aufruf läuft über <see cref="IMcpConfirmationService"/>,
|
/// <summary>Write-Tools (Phase 2+3) — jeder Aufruf läuft über <see cref="IMcpConfirmationService"/>,
|
||||||
@@ -41,5 +52,29 @@ public static class McpToolScope
|
|||||||
"update_lesson_phase",
|
"update_lesson_phase",
|
||||||
"remove_lesson_phase",
|
"remove_lesson_phase",
|
||||||
"add_lesson_attachment",
|
"add_lesson_attachment",
|
||||||
|
"move_lesson",
|
||||||
|
"add_lesson_competency",
|
||||||
|
"remove_lesson_competency",
|
||||||
|
"create_subject",
|
||||||
|
"update_subject",
|
||||||
|
"create_competency_domain",
|
||||||
|
"update_competency_domain",
|
||||||
|
"add_competency_item",
|
||||||
|
"update_competency_item",
|
||||||
|
"remove_competency_item",
|
||||||
|
"apply_untis_absence_status",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Löschende Write-Tools — ursprünglich eine bewusste, gezielte Ausnahme von der sonst
|
||||||
|
/// geltenden "v1 ohne Lösch-Tools"-Regel für "delete_lesson" (siehe Planungsdokument), inzwischen
|
||||||
|
/// (Nutzer-Nachtrag) um die endgültigen Löschungen für Fächer/Kompetenzbereiche erweitert — beide
|
||||||
|
/// ohne Papierkorb, wie delete_lesson. Getrennt von <see cref="AllowedWriteTools"/> aufgeführt,
|
||||||
|
/// damit diese Ausnahme beim Lesen sofort auffällt. <see cref="McpServerHostedService"/>
|
||||||
|
/// registriert diese Tools zusätzlich mit <c>Destructive = true</c>.</summary>
|
||||||
|
public static readonly IReadOnlyCollection<string> AllowedDestructiveWriteTools =
|
||||||
|
[
|
||||||
|
"delete_lesson",
|
||||||
|
"delete_subject",
|
||||||
|
"delete_competency_domain",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>MCP-Tools für die Fach- und Kompetenzkatalog-Verwaltung (Nutzer-Nachtrag zu 4.5.28ff.):
|
||||||
|
/// <see cref="Subject"/> und <see cref="CompetencyDomain"/>/<see cref="CompetencyItem"/> vollständig
|
||||||
|
/// anlegen/ändern/löschen. Die Zuordnung von Kompetenzcodes zu einzelnen Stunden lebt bewusst in
|
||||||
|
/// <see cref="LessonPlanTools"/> (AddLessonCompetency/RemoveLessonCompetency), nicht hier - sie
|
||||||
|
/// gehört fachlich zur Stundenverwaltung, nicht zum Katalog selbst.
|
||||||
|
///
|
||||||
|
/// "delete_subject" und "delete_competency_domain" sind wie "delete_lesson" (LessonPlanTools)
|
||||||
|
/// endgültige Löschungen ohne Papierkorb (die Repository-Implementierungen löschen direkt in der
|
||||||
|
/// LiteDB-Collection statt über MoveToTrash) - deshalb als Destructive registriert (siehe
|
||||||
|
/// McpServerHostedService) und mit entsprechend deutlicher Bestätigungsnachricht.</summary>
|
||||||
|
public class CompetencyTools(
|
||||||
|
ISubjectRepository subjects, ICompetencyDomainRepository domains, IMcpConfirmationService confirmation)
|
||||||
|
{
|
||||||
|
// ── Lesen ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Description("Listet alle Fächer.")]
|
||||||
|
public List<SubjectDto> GetSubjects() =>
|
||||||
|
subjects.GetAll().Select(s => new SubjectDto(s.Id, s.Name, s.ShortName)).ToList();
|
||||||
|
|
||||||
|
[Description("Listet den Kompetenzkatalog (Bereiche mit Einzelkompetenzen) eines Fachs, optional gefiltert auf eine Klassenstufe.")]
|
||||||
|
public List<CompetencyDomainDto> GetCompetencyCatalog(
|
||||||
|
[Description("Fach-ID, aus get_subjects.")] Guid subjectId,
|
||||||
|
[Description("Optionale Klassenstufe. Weglassen: alle Klassenstufen dieses Fachs.")] int? gradeLevel = null)
|
||||||
|
{
|
||||||
|
var result = gradeLevel is not null
|
||||||
|
? domains.GetBySubjectAndGrade(subjectId, gradeLevel.Value)
|
||||||
|
: domains.GetBySubject(subjectId);
|
||||||
|
return result.Select(ToDto).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fächer (Subject) ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Description("Legt ein neues Fach an. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> CreateSubject(
|
||||||
|
[Description("Name des Fachs, z.B. \"Mathematik\".")] string name,
|
||||||
|
[Description("Kurzform, z.B. \"Ma\".")] string shortName = "",
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) return new WriteResultDto(false, null, "Fachname darf nicht leer sein.");
|
||||||
|
|
||||||
|
var message = $"Neues Fach „{name.Trim()}“" +
|
||||||
|
(string.IsNullOrWhiteSpace(shortName) ? "" : $" ({shortName.Trim()})") + " anlegen?";
|
||||||
|
if (!await confirmation.ConfirmAsync("Fach anlegen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
var subject = new Subject { Name = name, ShortName = shortName };
|
||||||
|
try { subjects.Save(subject); }
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
return new WriteResultDto(false, null, ex.Message);
|
||||||
|
}
|
||||||
|
return new WriteResultDto(true, subject.Id, "Fach gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Ändert Name/Kurzform eines bestehenden Fachs. Nur angegebene Felder werden geändert.")]
|
||||||
|
public async Task<WriteResultDto> UpdateSubject(
|
||||||
|
[Description("Fach-ID.")] Guid subjectId,
|
||||||
|
[Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null,
|
||||||
|
[Description("Neue Kurzform. Unverändert lassen: weglassen.")] string? shortName = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var subject = subjects.GetById(subjectId);
|
||||||
|
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
|
||||||
|
|
||||||
|
var changes = new StringBuilder();
|
||||||
|
if (name is not null && name != subject.Name) { changes.AppendLine($"Name: „{subject.Name}“ → „{name}“"); subject.Name = name; }
|
||||||
|
if (shortName is not null && shortName != subject.ShortName) { changes.AppendLine($"Kurzform: „{subject.ShortName}“ → „{shortName}“"); subject.ShortName = shortName; }
|
||||||
|
|
||||||
|
if (changes.Length == 0)
|
||||||
|
return new WriteResultDto(true, subject.Id, "Keine Änderung nötig.");
|
||||||
|
|
||||||
|
if (!await confirmation.ConfirmAsync("Fach ändern?", $"„{subject.Name}“\n{changes}".TrimEnd(), ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
try { subjects.Save(subject); }
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
return new WriteResultDto(false, null, ex.Message);
|
||||||
|
}
|
||||||
|
return new WriteResultDto(true, subject.Id, "Fach gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Löscht ein Fach endgültig. Schlägt fehl, wenn noch eine Lerngruppe oder ein Kompetenzkatalog dieses Fach referenziert. Kein Papierkorb - nicht rückgängig zu machen. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> DeleteSubject(
|
||||||
|
[Description("Fach-ID.")] Guid subjectId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var subject = subjects.GetById(subjectId);
|
||||||
|
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
|
||||||
|
|
||||||
|
var message = $"Das Fach „{subject.Name}“ wird endgültig gelöscht. Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Fächer).";
|
||||||
|
if (!await confirmation.ConfirmAsync("Fach endgültig löschen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
try { subjects.Delete(subjectId); }
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return new WriteResultDto(false, null, ex.Message);
|
||||||
|
}
|
||||||
|
return new WriteResultDto(true, subjectId, "Fach gelöscht.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kompetenzbereiche (CompetencyDomain) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Description("Legt einen neuen Kompetenzbereich (z.B. \"Zahlen und Operationen\") für ein Fach und eine Klassenstufe an, zunächst ohne Einzelkompetenzen. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> CreateCompetencyDomain(
|
||||||
|
[Description("Fach-ID, aus get_subjects.")] Guid subjectId,
|
||||||
|
[Description("Klassenstufe.")] int gradeLevel,
|
||||||
|
[Description("Name des Bereichs.")] string name,
|
||||||
|
[Description("Optionales Kürzel, z.B. \"ZO\".")] string code = "",
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var subject = subjects.GetById(subjectId);
|
||||||
|
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) return new WriteResultDto(false, null, "Bereichsname darf nicht leer sein.");
|
||||||
|
|
||||||
|
var sortOrder = domains.GetBySubjectAndGrade(subjectId, gradeLevel).Count;
|
||||||
|
var message = $"Neuen Kompetenzbereich „{name.Trim()}“ für {subject.Name}, Klasse {gradeLevel}, anlegen?";
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenzbereich anlegen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
var domain = new CompetencyDomain
|
||||||
|
{
|
||||||
|
SubjectId = subjectId, GradeLevel = gradeLevel, Name = name, Code = code, SortOrder = sortOrder,
|
||||||
|
};
|
||||||
|
domains.Save(domain);
|
||||||
|
return new WriteResultDto(true, domain.Id, "Kompetenzbereich gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Ändert Name/Kürzel eines bestehenden Kompetenzbereichs. Nur angegebene Felder werden geändert.")]
|
||||||
|
public async Task<WriteResultDto> UpdateCompetencyDomain(
|
||||||
|
[Description("ID des Kompetenzbereichs, aus get_competency_catalog.")] Guid domainId,
|
||||||
|
[Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null,
|
||||||
|
[Description("Neues Kürzel. Unverändert lassen: weglassen.")] string? code = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var domain = domains.GetById(domainId);
|
||||||
|
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||||
|
|
||||||
|
var changes = new StringBuilder();
|
||||||
|
if (name is not null && name != domain.Name) { changes.AppendLine($"Name: „{domain.Name}“ → „{name}“"); domain.Name = name; }
|
||||||
|
if (code is not null && code != domain.Code) { changes.AppendLine($"Kürzel: „{domain.Code}“ → „{code}“"); domain.Code = code; }
|
||||||
|
|
||||||
|
if (changes.Length == 0)
|
||||||
|
return new WriteResultDto(true, domain.Id, "Keine Änderung nötig.");
|
||||||
|
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenzbereich ändern?", $"„{domain.Name}“\n{changes}".TrimEnd(), ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
domains.Save(domain);
|
||||||
|
return new WriteResultDto(true, domain.Id, "Kompetenzbereich gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Löscht einen Kompetenzbereich endgültig, inklusive aller darin enthaltenen Einzelkompetenzen. Bereits vergebene Kompetenzcodes an Einheiten/Stunden/Klausuraufgaben bleiben als Freitext bestehen, gelten aber danach als \"nicht im Katalog\". Kein Papierkorb - nicht rückgängig zu machen. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> DeleteCompetencyDomain(
|
||||||
|
[Description("ID des Kompetenzbereichs.")] Guid domainId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var domain = domains.GetById(domainId);
|
||||||
|
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||||
|
|
||||||
|
var message = $"Der Kompetenzbereich „{domain.Name}“ mit {domain.Items.Count} Einzelkompetenz(en) wird endgültig gelöscht. " +
|
||||||
|
"Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Kompetenzbereiche).";
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenzbereich endgültig löschen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
domains.Delete(domainId);
|
||||||
|
return new WriteResultDto(true, domainId, "Kompetenzbereich gelöscht.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Einzelkompetenzen (CompetencyItem) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Description("Fügt einem Kompetenzbereich eine neue Einzelkompetenz hinzu (ans Ende). Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> AddCompetencyItem(
|
||||||
|
[Description("ID des Kompetenzbereichs, aus get_competency_catalog.")] Guid domainId,
|
||||||
|
[Description("Kompetenzcode, z.B. \"M.5.1\".")] string code,
|
||||||
|
[Description("Beschreibung der Kompetenz.")] string description,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var domain = domains.GetById(domainId);
|
||||||
|
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||||
|
if (string.IsNullOrWhiteSpace(description)) return new WriteResultDto(false, null, "Beschreibung darf nicht leer sein.");
|
||||||
|
|
||||||
|
var message = $"Neue Kompetenz „{code}“ zu Bereich „{domain.Name}“ hinzufügen?\n{description}";
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenz hinzufügen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
var item = new CompetencyItem { Code = code.Trim(), Description = description.Trim(), SortOrder = domain.Items.Count };
|
||||||
|
domain.Items.Add(item);
|
||||||
|
domains.Save(domain);
|
||||||
|
return new WriteResultDto(true, item.Id, "Kompetenz hinzugefügt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Ändert Code/Beschreibung einer bestehenden Einzelkompetenz. Nur angegebene Felder werden geändert.")]
|
||||||
|
public async Task<WriteResultDto> UpdateCompetencyItem(
|
||||||
|
[Description("ID des Kompetenzbereichs.")] Guid domainId,
|
||||||
|
[Description("ID der Einzelkompetenz, aus get_competency_catalog.")] Guid itemId,
|
||||||
|
[Description("Neuer Kompetenzcode. Unverändert lassen: weglassen.")] string? code = null,
|
||||||
|
[Description("Neue Beschreibung. Unverändert lassen: weglassen.")] string? description = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var domain = domains.GetById(domainId);
|
||||||
|
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||||
|
var item = domain.Items.FirstOrDefault(i => i.Id == itemId);
|
||||||
|
if (item is null) return new WriteResultDto(false, null, "Unbekannte Kompetenz-ID in diesem Bereich.");
|
||||||
|
|
||||||
|
var changes = new StringBuilder();
|
||||||
|
if (code is not null && code != item.Code) { changes.AppendLine($"Code: „{item.Code}“ → „{code}“"); item.Code = code; }
|
||||||
|
if (description is not null && description != item.Description) { changes.AppendLine($"Beschreibung: „{item.Description}“ → „{description}“"); item.Description = description; }
|
||||||
|
|
||||||
|
if (changes.Length == 0)
|
||||||
|
return new WriteResultDto(true, item.Id, "Keine Änderung nötig.");
|
||||||
|
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenz ändern?", $"Kompetenz „{item.Code}“ in Bereich „{domain.Name}“\n{changes}".TrimEnd(), ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
domains.Save(domain);
|
||||||
|
return new WriteResultDto(true, item.Id, "Kompetenz gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Entfernt eine Einzelkompetenz aus einem Kompetenzbereich. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> RemoveCompetencyItem(
|
||||||
|
[Description("ID des Kompetenzbereichs.")] Guid domainId,
|
||||||
|
[Description("ID der Einzelkompetenz, aus get_competency_catalog.")] Guid itemId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var domain = domains.GetById(domainId);
|
||||||
|
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||||
|
var item = domain.Items.FirstOrDefault(i => i.Id == itemId);
|
||||||
|
if (item is null) return new WriteResultDto(false, null, "Unbekannte Kompetenz-ID in diesem Bereich.");
|
||||||
|
|
||||||
|
var message = $"Kompetenz „{item.Code}“ ({item.Description}) aus Bereich „{domain.Name}“ entfernen?";
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenz entfernen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
domain.Items.Remove(item);
|
||||||
|
domains.Save(domain);
|
||||||
|
return new WriteResultDto(true, item.Id, "Kompetenz entfernt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CompetencyDomainDto ToDto(CompetencyDomain d) => new(
|
||||||
|
d.Id, d.SubjectId, d.GradeLevel, d.Name, d.Code, d.SortOrder,
|
||||||
|
d.Items.OrderBy(i => i.SortOrder)
|
||||||
|
.Select(i => new CompetencyItemDto(i.Id, i.Code, i.Description, i.SortOrder)).ToList());
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ public record LessonAttachmentDto(string StorageId, string FileName, long SizeBy
|
|||||||
|
|
||||||
public record LessonDto(
|
public record LessonDto(
|
||||||
Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic,
|
Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic,
|
||||||
string? Homework, LessonStatus Status, List<LessonPhaseDto> Phases,
|
string? Homework, LessonStatus Status, List<string> Competencies, List<LessonPhaseDto> Phases,
|
||||||
List<LessonAttachmentDto> Attachments);
|
List<LessonAttachmentDto> Attachments);
|
||||||
|
|
||||||
/// <summary>Ergebnis von "download_lesson_attachment": Inhalt Base64-kodiert, weil MCP-Tool-Antworten
|
/// <summary>Ergebnis von "download_lesson_attachment": Inhalt Base64-kodiert, weil MCP-Tool-Antworten
|
||||||
@@ -45,6 +45,14 @@ public record UnitDto(
|
|||||||
|
|
||||||
public record LessonPlanResultDto(List<UnitDto> Units, List<LessonDto> Lessons);
|
public record LessonPlanResultDto(List<UnitDto> Units, List<LessonDto> Lessons);
|
||||||
|
|
||||||
|
public record SubjectDto(Guid Id, string Name, string ShortName);
|
||||||
|
|
||||||
|
public record CompetencyItemDto(Guid Id, string Code, string Description, int SortOrder);
|
||||||
|
|
||||||
|
public record CompetencyDomainDto(
|
||||||
|
Guid Id, Guid SubjectId, int GradeLevel, string Name, string Code, int SortOrder,
|
||||||
|
List<CompetencyItemDto> Items);
|
||||||
|
|
||||||
public record GroupMembershipDto(
|
public record GroupMembershipDto(
|
||||||
Guid Id, Guid StudentId, Guid GroupId, MembershipPeriod Period,
|
Guid Id, Guid StudentId, Guid GroupId, MembershipPeriod Period,
|
||||||
DateOnly? JoinedAt, DateOnly? LeftAt, Niveau? Niveau);
|
DateOnly? JoinedAt, DateOnly? LeftAt, Niveau? Niveau);
|
||||||
@@ -61,3 +69,29 @@ public record PlaceholderInfoDto(string Name, string Type, bool Required, bool I
|
|||||||
public record LetterTemplateDto(string Id, string Name, string Description, List<PlaceholderInfoDto> Placeholders);
|
public record LetterTemplateDto(string Id, string Name, string Description, List<PlaceholderInfoDto> Placeholders);
|
||||||
|
|
||||||
public record LetterRenderResultDto(bool Success, string Message, string? Base64Pdf, string? SuggestedFileName);
|
public record LetterRenderResultDto(bool Success, string Message, string? Base64Pdf, string? SuggestedFileName);
|
||||||
|
|
||||||
|
/// <summary>Eine Zeile des Untis-Hub (siehe UntisHubService) - "Kind"/"DueState" als Text statt
|
||||||
|
/// Enum-Wert, damit ein KI-Client sie ohne Kenntnis des internen Enums lesen kann.</summary>
|
||||||
|
public record UntisHubStatusRowDto(
|
||||||
|
string Kind, string GroupName, string DueState, string DueLabel,
|
||||||
|
DateTime? LastRunAt, string? LastResultSummary);
|
||||||
|
|
||||||
|
/// <summary>Anonymisierte Fehlzeiten-Diskrepanz (siehe UntisComparisonTools.GetUntisAbsenceRows):
|
||||||
|
/// bewusst KEIN Schülername/keine Klasse - <see cref="RowId"/> ist die einzige Kennung, über die
|
||||||
|
/// UntisComparisonTools.ApplyUntisAbsenceStatus später zurückordnet.</summary>
|
||||||
|
public record UntisAbsenceRowDto(
|
||||||
|
string RowId, DateOnly Date, string ReasonText, int AbsentMinutes, bool HandledOn,
|
||||||
|
bool? ExternKeyInParentheses, string CurrentLocalStatus, string CurrentGuessStatus);
|
||||||
|
|
||||||
|
/// <summary>Eine Zeile aus dem namentlichen Ausnahmeweg (get_named_untis_absence_pattern) - im
|
||||||
|
/// Unterschied zu <see cref="UntisAbsenceRowDto"/> bewusst MIT Schülername, da genau diese
|
||||||
|
/// Zusammenführung von Name und Fehlzeitendaten der Zweck des Aufrufs ist (z.B. Fehlmuster-Vergleich
|
||||||
|
/// zwischen zwei Schülern) und der Nutzer sie je Anfrage einzeln freigegeben hat.</summary>
|
||||||
|
public record NamedUntisAbsenceRowDto(
|
||||||
|
Guid StudentId, string StudentFullName, DateOnly Date, string ReasonText,
|
||||||
|
string CurrentLocalStatus, string CurrentGuessStatus);
|
||||||
|
|
||||||
|
/// <summary><see cref="Granted"/> ist false bei Ablehnung/Timeout oder wenn keine der angegebenen
|
||||||
|
/// Schüler-IDs bekannt war - <see cref="Rows"/> ist dann null, nicht nur leer, damit ein KI-Client
|
||||||
|
/// "abgelehnt" nicht mit "keine Fehlzeiten gefunden" verwechselt.</summary>
|
||||||
|
public record NamedUntisAbsenceResultDto(bool Granted, string Message, List<NamedUntisAbsenceRowDto>? Rows);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.ComponentModel;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
@@ -16,7 +17,12 @@ namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
|||||||
/// Stunde) und liefern einen für den Bestätigungsdialog tatsächlich lesbaren Diff statt eines
|
/// Stunde) und liefern einen für den Bestätigungsdialog tatsächlich lesbaren Diff statt eines
|
||||||
/// kompletten Objekt-Dumps. Ein Tool, das die ganze Stunde überschreibt, ist bewusst NICHT
|
/// kompletten Objekt-Dumps. Ein Tool, das die ganze Stunde überschreibt, ist bewusst NICHT
|
||||||
/// vorgesehen; wo es fehlt, ist die Kombination aus update_lesson (Metadaten) +
|
/// vorgesehen; wo es fehlt, ist die Kombination aus update_lesson (Metadaten) +
|
||||||
/// add/update/remove_lesson_phase (je eine Phase) der vorgesehene Weg.</summary>
|
/// add/update/remove_lesson_phase (je eine Phase) der vorgesehene Weg.
|
||||||
|
///
|
||||||
|
/// "delete_lesson" ist (Stand Nutzer-Nachtrag) das einzige Lösch-Tool im gesamten MCP-Katalog —
|
||||||
|
/// eine bewusste, gezielte Ausnahme von der sonst geltenden "v1 ohne Lösch-Tools"-Regel (siehe
|
||||||
|
/// Planungsdokument), nicht deren Aufhebung. Entsprechend als "Destructive" annotiert
|
||||||
|
/// (siehe McpServerHostedService) und mit besonders deutlicher Bestätigungsnachricht.</summary>
|
||||||
public class LessonPlanTools(
|
public class LessonPlanTools(
|
||||||
IUnitRepository units, ILessonRepository lessons, IGroupRepository groups,
|
IUnitRepository units, ILessonRepository lessons, IGroupRepository groups,
|
||||||
IAttachmentStorage attachments, IMcpConfirmationService confirmation)
|
IAttachmentStorage attachments, IMcpConfirmationService confirmation)
|
||||||
@@ -187,7 +193,7 @@ public class LessonPlanTools(
|
|||||||
[Description("ID der Einzelstunde.")] Guid lessonId,
|
[Description("ID der Einzelstunde.")] Guid lessonId,
|
||||||
[Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null,
|
[Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null,
|
||||||
[Description("Neue Hausaufgabe. Unverändert lassen: weglassen.")] string? homework = null,
|
[Description("Neue Hausaufgabe. Unverändert lassen: weglassen.")] string? homework = null,
|
||||||
[Description("Neuer Status: Planned, Conducted, Draft oder Ready. Unverändert lassen: weglassen.")] LessonStatus? status = null,
|
[Description("Neuer Status: Planned, Conducted, Draft, Ready oder Cancelled (ausgefallen, z.B. Exkursion/Feiertag - Alternative zu delete_lesson, wenn die Stunde als Ereignis dokumentiert bleiben soll). Unverändert lassen: weglassen.")] LessonStatus? status = null,
|
||||||
[Description("Neuer Stundenbeginn, Format HH:mm. Unverändert lassen: weglassen.")] TimeOnly? startTime = null,
|
[Description("Neuer Stundenbeginn, Format HH:mm. Unverändert lassen: weglassen.")] TimeOnly? startTime = null,
|
||||||
[Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null,
|
[Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
@@ -294,8 +300,116 @@ public class LessonPlanTools(
|
|||||||
return new WriteResultDto(true, phase.Id, "Phase entfernt.");
|
return new WriteResultDto(true, phase.Id, "Phase entfernt.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Einzelstunden (Lesson) — Kompetenzzuordnung ──────────────────────────────────────────
|
||||||
|
// Auf Stundenebene (Lesson.Competencies), noch nicht je Phase - siehe TODO.md 4.5.8. Getrennte
|
||||||
|
// add/remove-Tools statt eines "set_lesson_competencies", das die ganze Liste ersetzt: dieselbe
|
||||||
|
// Race-Condition-Überlegung wie bei den Verlaufsplan-Phasen (add/update/remove_lesson_phase) -
|
||||||
|
// ein Tool-Aufruf ändert nur einen Code, kein "letzter Schreiber gewinnt" über die ganze Liste.
|
||||||
|
|
||||||
|
[Description("Ordnet einer Einzelstunde einen Kompetenzcode aus dem Katalog zu (siehe get_competency_catalog). Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> AddLessonCompetency(
|
||||||
|
[Description("ID der Einzelstunde.")] Guid lessonId,
|
||||||
|
[Description("Kompetenzcode, z.B. \"M.5.1\".")] string code,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var lesson = lessons.GetById(lessonId);
|
||||||
|
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
|
||||||
|
code = code.Trim();
|
||||||
|
if (code.Length == 0) return new WriteResultDto(false, null, "Leerer Kompetenzcode.");
|
||||||
|
if (lesson.Competencies.Contains(code, StringComparer.OrdinalIgnoreCase))
|
||||||
|
return new WriteResultDto(true, lesson.Id, "Kompetenzcode ist bereits zugeordnet.");
|
||||||
|
|
||||||
|
var message = $"Kompetenz „{code}“ zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?";
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenz zuordnen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
lesson.Competencies.Add(code);
|
||||||
|
lessons.Save(lesson);
|
||||||
|
return new WriteResultDto(true, lesson.Id, "Kompetenz zugeordnet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Entfernt einen Kompetenzcode von einer Einzelstunde. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> RemoveLessonCompetency(
|
||||||
|
[Description("ID der Einzelstunde.")] Guid lessonId,
|
||||||
|
[Description("Kompetenzcode, wie in get_lesson_plans hinterlegt.")] string code,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var lesson = lessons.GetById(lessonId);
|
||||||
|
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
|
||||||
|
var existing = lesson.Competencies.FirstOrDefault(c => string.Equals(c, code, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (existing is null) return new WriteResultDto(false, null, "Dieser Kompetenzcode ist dieser Stunde nicht zugeordnet.");
|
||||||
|
|
||||||
|
var message = $"Kompetenz „{existing}“ von „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} entfernen?";
|
||||||
|
if (!await confirmation.ConfirmAsync("Kompetenz entfernen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
lesson.Competencies.Remove(existing);
|
||||||
|
lessons.Save(lesson);
|
||||||
|
return new WriteResultDto(true, lesson.Id, "Kompetenz entfernt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Einzelstunden (Lesson) — Verschieben & Löschen ───────────────────────────────────────
|
||||||
|
|
||||||
|
[Description("Verschiebt eine Einzelstunde auf ein neues Datum (und optional eine neue Stundennummer). Mit shiftFollowingLessons=true verschieben sich alle noch nicht durchgeführten, späteren Stunden derselben Einheit um denselben Tages-Versatz mit — so lässt sich eine Lücke für eine neue Stunde öffnen: diese Stunde auf den Termin der übernächsten verschieben (mit shiftFollowingLessons), dann create_lesson auf das dadurch freigewordene ursprüngliche Datum. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> MoveLesson(
|
||||||
|
[Description("ID der zu verschiebenden Einzelstunde.")] Guid lessonId,
|
||||||
|
[Description("Neues Datum, Format YYYY-MM-DD.")] DateOnly newDate,
|
||||||
|
[Description("Neue Stundennummer im Tagesraster. Unverändert lassen: weglassen.")] int? newPeriod = null,
|
||||||
|
[Description("Alle späteren, noch nicht durchgeführten Stunden derselben Einheit um denselben Tages-Versatz mitverschieben.")] bool shiftFollowingLessons = false,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var lesson = lessons.GetById(lessonId);
|
||||||
|
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
|
||||||
|
|
||||||
|
var oldDate = lesson.Date;
|
||||||
|
// Muss exakt dieselbe Schutzbedingung wie LessonSchedulingService.Move verwenden, sonst
|
||||||
|
// weicht die Vorschau-Zahl im Bestätigungstext von dem ab, was tatsächlich verschoben wird.
|
||||||
|
var affectedCount = shiftFollowingLessons
|
||||||
|
? lessons.GetByUnit(lesson.UnitId).Count(l =>
|
||||||
|
l.Id != lesson.Id && l.Status is not (LessonStatus.Conducted or LessonStatus.Cancelled) && l.Date > oldDate)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
var message = $"„{lesson.Topic}“ von {oldDate:dd.MM.yyyy} auf {newDate:dd.MM.yyyy} verschieben?" +
|
||||||
|
(newPeriod is not null ? $"\nNeue Stundennummer: {newPeriod}." : "") +
|
||||||
|
(shiftFollowingLessons
|
||||||
|
? affectedCount > 0
|
||||||
|
? $"\n{affectedCount} spätere Stunde(n) derselben Einheit verschieben sich um denselben Versatz mit."
|
||||||
|
: "\nKeine späteren, noch offenen Stunden derselben Einheit betroffen."
|
||||||
|
: "");
|
||||||
|
|
||||||
|
if (!await confirmation.ConfirmAsync("Stunde verschieben?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
new LessonSchedulingService(lessons).Move(lesson, newDate, newPeriod, shiftFollowingLessons);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return new WriteResultDto(false, null, ex.Message);
|
||||||
|
}
|
||||||
|
return new WriteResultDto(true, lesson.Id, "Stunde verschoben.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Löscht eine Einzelstunde endgültig, inklusive ihrer Anhänge. Anders als die meisten anderen Löschvorgänge in LehrerApp landet eine gelöschte Stunde NICHT im Papierkorb — nicht rückgängig zu machen. Für eine Stunde, die nur ausgefallen ist (Exkursion, Feiertag, Vertretung ohne Ersatztermin) aber als Ereignis dokumentiert bleiben soll, ist update_lesson mit status=Cancelled meist die bessere Wahl. Muss der Nutzer erst bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> DeleteLesson(
|
||||||
|
[Description("ID der zu löschenden Einzelstunde.")] Guid lessonId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var lesson = lessons.GetById(lessonId);
|
||||||
|
if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID.");
|
||||||
|
|
||||||
|
var message = $"Die Stunde „{lesson.Topic}“ vom {lesson.Date:dd.MM.yyyy} wird endgültig gelöscht. " +
|
||||||
|
"Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Stunden).";
|
||||||
|
if (!await confirmation.ConfirmAsync("Stunde endgültig löschen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
lessons.Delete(lessonId);
|
||||||
|
return new WriteResultDto(true, lessonId, "Stunde gelöscht.");
|
||||||
|
}
|
||||||
|
|
||||||
private static LessonDto ToDto(Lesson l) => new(
|
private static LessonDto ToDto(Lesson l) => new(
|
||||||
l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.Status,
|
l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.Status, l.Competencies,
|
||||||
l.Phases.Select(p => new LessonPhaseDto(p.Id, p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand)).ToList(),
|
l.Phases.Select(p => new LessonPhaseDto(p.Id, p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand)).ToList(),
|
||||||
l.Attachments.Select(a => new LessonAttachmentDto(a.StorageId, a.FileName, a.SizeBytes)).ToList());
|
l.Attachments.Select(a => new LessonAttachmentDto(a.StorageId, a.FileName, a.SizeBytes)).ToList());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MCP-Tools für die WebUntis-Abgleiche (Nutzer-Nachtrag zum Untis-Hub, siehe TODO.md), zweigleisig
|
||||||
|
/// wie vom Nutzer entschieden:
|
||||||
|
///
|
||||||
|
/// - <see cref="GetUntisAbsenceRows"/>/<see cref="ApplyUntisAbsenceStatus"/> (bevorzugter Weg): eine
|
||||||
|
/// rein technische <c>row-id</c> ordnet zurück, nie ein Schülername - nur Zeilen, die WebUntis
|
||||||
|
/// selbst eindeutig über die externe Schülernummer (ENr) einem Kursmitglied zuordnet, werden
|
||||||
|
/// überhaupt gelistet (unklare, nur namensbasiert auflösbare Fälle fließen bewusst NICHT hier
|
||||||
|
/// hinein, siehe <see cref="GetUntisAbsenceRows"/>).
|
||||||
|
/// - <see cref="GetNamedUntisAbsencePattern"/> (bewusste, eng begrenzte Ausnahme von der sonst in
|
||||||
|
/// <see cref="McpToolScope"/> geltenden Regel, dass personenbezogene Verhaltens-/Anwesenheitsdaten
|
||||||
|
/// nie mit einem Namen verknüpft nach außen gehen): exponiert Name UND Fehlzeiten gemeinsam, aber
|
||||||
|
/// nur für explizit angegebene Schüler-IDs und nur nach JEDES MAL gesonderter, prominenter
|
||||||
|
/// Bestätigung ohne Sitzungsfreigabe (<c>allowSessionTrust: false</c>).
|
||||||
|
///
|
||||||
|
/// <see cref="GetUntisHubStatus"/> ergänzt beide Wege um einen Überblick, welche Abgleiche laut
|
||||||
|
/// <see cref="UntisHubService"/> überhaupt fällig sind, ohne selbst WebUntis anzufragen.
|
||||||
|
/// </summary>
|
||||||
|
public class UntisComparisonTools(
|
||||||
|
IGroupRepository groups, IStudentRepository students, IParticipationSessionRepository sessions,
|
||||||
|
IParticipationRepository participation, WebUntisIntegrationService untis, UntisHubService hub,
|
||||||
|
IMcpConfirmationService confirmation)
|
||||||
|
{
|
||||||
|
// Statuswerte, die ApplyUntisAbsenceStatus akzeptiert - dieselbe Einschränkung wie
|
||||||
|
// WebUntisLessonAbsenceRow.SelectableStatuses im interaktiven Dialog (nicht z.B. "Geschwänzt"
|
||||||
|
// oder "Suspendiert", die WebUntis hier nie meldet). Eigenständig gehalten statt der Row-Klasse
|
||||||
|
// referenziert, da Tool-Klassen unter Services/Mcp nicht von ViewModel-Klassen abhängen sollen
|
||||||
|
// (siehe GradeTools).
|
||||||
|
private static readonly AttendanceStatus[] SelectableStatuses =
|
||||||
|
[
|
||||||
|
AttendanceStatus.ExcusePending, AttendanceStatus.Excused, AttendanceStatus.Unexcused,
|
||||||
|
AttendanceStatus.Late, AttendanceStatus.LeftDuringClass, AttendanceStatus.Present,
|
||||||
|
];
|
||||||
|
|
||||||
|
// In-Memory, pro Prozesslaufzeit - eine row-id aus GetUntisAbsenceRows ist nur bis zum nächsten
|
||||||
|
// Neustart von LehrerApp gültig; danach muss der KI-Client die Liste erneut abrufen. Bewusst
|
||||||
|
// keine Ablauf-/Größenbegrenzung (siehe Nutzerdiskussion: geringe Nutzungsfrequenz, winzige
|
||||||
|
// Einträge) - ein v1-Kompromiss, kein Deployment-Risiko wie bei den ai-backend-Endpunkten.
|
||||||
|
private readonly ConcurrentDictionary<string, PendingAbsenceRow> _pendingRows = new();
|
||||||
|
|
||||||
|
private sealed record PendingAbsenceRow(Guid StudentId, Guid SessionId, Guid GroupId, DateOnly Date);
|
||||||
|
|
||||||
|
[Description("Listet die Fälligkeit der Untis-Hub-Abgleiche (Fehlzeiten je Lerngruppe, offene Stunden, Klassenbuch-/Hausaufgabenabgleich) - reine Lesefunktion aus der lokalen Fälligkeits-Historie, kein eigener WebUntis-Zugriff.")]
|
||||||
|
public List<UntisHubStatusRowDto> GetUntisHubStatus() =>
|
||||||
|
hub.GetRows().Select(r => new UntisHubStatusRowDto(
|
||||||
|
r.Kind.ToString(), r.GroupName, r.DueState.ToString(), r.DueLabel, r.LastRunAt, r.LastResultSummary)).ToList();
|
||||||
|
|
||||||
|
[Description("""
|
||||||
|
Listet Fehlzeiten-Diskrepanzen einer Lerngruppe gegenüber WebUntis in einem Zeitraum, ANONYMISIERT:
|
||||||
|
enthält keinen Schülernamen, nur eine technische row-id je Zeile (für apply_untis_absence_status).
|
||||||
|
Enthält nur Zeilen, die WebUntis über die externe Schülernummer eindeutig einem Kursmitglied zuordnen
|
||||||
|
konnte - Zeilen, die nur über den Namen auflösbar wären, fehlen hier bewusst; für die braucht es
|
||||||
|
get_named_untis_absence_pattern (Namen exponierend, gesondert bestätigungspflichtig).
|
||||||
|
""")]
|
||||||
|
public async Task<List<UntisAbsenceRowDto>> GetUntisAbsenceRows(
|
||||||
|
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||||
|
[Description("Startdatum, Format YYYY-MM-DD.")] DateOnly startDate,
|
||||||
|
[Description("Enddatum, Format YYYY-MM-DD.")] DateOnly endDate,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var group = groups.GetById(groupId);
|
||||||
|
if (group?.WebUntisLessonId is not { } lessonId) return [];
|
||||||
|
|
||||||
|
var courseStudents = students.GetByGroup(groupId);
|
||||||
|
var byExternKey = courseStudents
|
||||||
|
.Select(s => (Student: s, Key: UntisLessonAbsenceHelper.StudentExternKey(s)))
|
||||||
|
.Where(x => x.Key is not null)
|
||||||
|
.ToDictionary(x => x.Key!.Value, x => x.Student);
|
||||||
|
var localSessions = sessions.GetByGroup(groupId)
|
||||||
|
.Where(s => s.Date >= startDate && s.Date <= endDate)
|
||||||
|
.GroupBy(s => s.Date).ToDictionary(g => g.Key, g => g.First());
|
||||||
|
|
||||||
|
var absences = await untis.GetLessonAbsencesAsync(lessonId, startDate, endDate, ct);
|
||||||
|
var rows = new List<UntisAbsenceRowDto>();
|
||||||
|
foreach (var absence in absences)
|
||||||
|
{
|
||||||
|
if (absence.ExternKey is not { } key || !byExternKey.TryGetValue(key, out var student)) continue;
|
||||||
|
if (!TryParseDate(absence.Date, out var date) || !localSessions.TryGetValue(date, out var session)) continue;
|
||||||
|
|
||||||
|
var rowId = Guid.NewGuid().ToString("N");
|
||||||
|
_pendingRows[rowId] = new PendingAbsenceRow(student.Id, session.Id, groupId, date);
|
||||||
|
|
||||||
|
var entry = participation.GetBySessionAndStudent(session.Id, student.Id);
|
||||||
|
var guess = UntisLessonAbsenceHelper.MapStatus(absence);
|
||||||
|
rows.Add(new UntisAbsenceRowDto(
|
||||||
|
rowId, date, absence.Reason ?? "", absence.AbsentMinutes,
|
||||||
|
!string.IsNullOrWhiteSpace(absence.HandledOn),
|
||||||
|
absence.ExternKey is null ? null : absence.ExternKeyInParentheses,
|
||||||
|
LocalStatusLabel(entry?.Attendance), guess.ToString()));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("Übernimmt einen Statusvorschlag für eine über get_untis_absence_rows gelieferte row-id in den lokalen Anwesenheitsstatus. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen; die Bestätigungsmeldung nennt bewusst KEINEN Schülernamen (nur Datum, Lerngruppe, Zielstatus).")]
|
||||||
|
public async Task<WriteResultDto> ApplyUntisAbsenceStatus(
|
||||||
|
[Description("row-id aus get_untis_absence_rows.")] string rowId,
|
||||||
|
[Description("Zielstatus: Present, Late, LeftDuringClass, ExcusePending, Excused oder Unexcused.")] string status,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<AttendanceStatus>(status, out var target) || !SelectableStatuses.Contains(target))
|
||||||
|
return new WriteResultDto(false, null, "Unbekannter oder nicht zulässiger Status.");
|
||||||
|
if (!_pendingRows.TryGetValue(rowId, out var row))
|
||||||
|
return new WriteResultDto(false, null, "Unbekannte oder abgelaufene row-id - zuerst get_untis_absence_rows erneut aufrufen.");
|
||||||
|
|
||||||
|
var groupName = groups.GetById(row.GroupId)?.Name ?? "?";
|
||||||
|
var message = $"Fehlzeile vom {row.Date:dd.MM.yyyy} in Lerngruppe „{groupName}“: " +
|
||||||
|
$"Anwesenheitsstatus auf „{LocalStatusLabel(target)}“ setzen?";
|
||||||
|
if (!await confirmation.ConfirmAsync("Fehlzeiten-Status übernehmen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
var entry = participation.GetBySessionAndStudent(row.SessionId, row.StudentId)
|
||||||
|
?? new ParticipationEntry { SessionId = row.SessionId, StudentId = row.StudentId };
|
||||||
|
entry.Attendance = target;
|
||||||
|
entry.UpdatedAt = DateTime.UtcNow;
|
||||||
|
participation.Save(entry);
|
||||||
|
_pendingRows.TryRemove(rowId, out _);
|
||||||
|
return new WriteResultDto(true, entry.Id, "Status übernommen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Description("""
|
||||||
|
Liefert Fehlzeiten für EXPLIZIT angegebene Schüler-IDs MIT Namen (z.B. für einen Bericht oder einen
|
||||||
|
Fehlmuster-Vergleich zwischen zwei Schülern) - bewusste, eng begrenzte Ausnahme von der sonst
|
||||||
|
geltenden Anonymisierung (siehe get_untis_absence_rows). So wenige studentIds wie für die Anfrage
|
||||||
|
nötig angeben, nicht den ganzen Kurs. Erfordert JEDES MAL eine gesonderte, prominente
|
||||||
|
Nutzerbestätigung ohne Sitzungsfreigabe - liefert bei Ablehnung granted:false und keine Zeilen.
|
||||||
|
""")]
|
||||||
|
public async Task<NamedUntisAbsenceResultDto> GetNamedUntisAbsencePattern(
|
||||||
|
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||||
|
[Description("Ids der Schüler, für die Name UND Fehlzeiten gemeinsam offengelegt werden sollen.")] List<Guid> studentIds,
|
||||||
|
[Description("Startdatum, Format YYYY-MM-DD.")] DateOnly startDate,
|
||||||
|
[Description("Enddatum, Format YYYY-MM-DD.")] DateOnly endDate,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var group = groups.GetById(groupId);
|
||||||
|
if (group?.WebUntisLessonId is not { } lessonId)
|
||||||
|
return new NamedUntisAbsenceResultDto(false, "Für diese Lerngruppe ist keine WebUntis-Unterrichtsnummer hinterlegt.", null);
|
||||||
|
|
||||||
|
var resolvedStudents = studentIds.Distinct()
|
||||||
|
.Select(students.GetById).Where(s => s is not null).Cast<Student>().ToList();
|
||||||
|
if (resolvedStudents.Count == 0)
|
||||||
|
return new NamedUntisAbsenceResultDto(false, "Keine der angegebenen Schüler-IDs ist bekannt.", null);
|
||||||
|
|
||||||
|
var names = string.Join(", ", resolvedStudents.Select(s => s.FullName));
|
||||||
|
var message = $"Name UND Fehlzeiten gemeinsam an den KI-Assistenten weitergeben für:\n{names}\n\n" +
|
||||||
|
$"Zeitraum: {startDate:dd.MM.yyyy}–{endDate:dd.MM.yyyy}, Lerngruppe „{group.Name}“.";
|
||||||
|
if (!await confirmation.ConfirmAsync("Namentliche Fehlzeitenauskunft freigeben?", message, ct, allowSessionTrust: false))
|
||||||
|
return new NamedUntisAbsenceResultDto(false, "Vom Nutzer abgelehnt oder nicht bestätigt.", null);
|
||||||
|
|
||||||
|
var byExternKey = resolvedStudents
|
||||||
|
.Select(s => (Student: s, Key: UntisLessonAbsenceHelper.StudentExternKey(s)))
|
||||||
|
.Where(x => x.Key is not null)
|
||||||
|
.ToDictionary(x => x.Key!.Value, x => x.Student);
|
||||||
|
var byName = resolvedStudents
|
||||||
|
.SelectMany(s => new[]
|
||||||
|
{
|
||||||
|
NameKey($"{s.LastName} {s.FirstName}"), NameKey($"{s.FirstName} {s.LastName}"),
|
||||||
|
}.Select(key => (Key: key, Student: s)))
|
||||||
|
.GroupBy(x => x.Key).Where(g => g.Select(x => x.Student).Distinct().Count() == 1)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Student);
|
||||||
|
var localSessions = sessions.GetByGroup(groupId)
|
||||||
|
.Where(s => s.Date >= startDate && s.Date <= endDate)
|
||||||
|
.GroupBy(s => s.Date).ToDictionary(g => g.Key, g => g.First());
|
||||||
|
|
||||||
|
var absences = await untis.GetLessonAbsencesAsync(lessonId, startDate, endDate, ct);
|
||||||
|
var rows = new List<NamedUntisAbsenceRowDto>();
|
||||||
|
foreach (var absence in absences)
|
||||||
|
{
|
||||||
|
var match = absence.ExternKey is { } key && byExternKey.TryGetValue(key, out var byKeyStudent)
|
||||||
|
? byKeyStudent
|
||||||
|
: byName.GetValueOrDefault(NameKey(absence.StudentName));
|
||||||
|
if (match is null) continue; // nur die explizit freigegebenen Schüler, nie "geraten"
|
||||||
|
if (!TryParseDate(absence.Date, out var date)) continue;
|
||||||
|
|
||||||
|
var entry = localSessions.TryGetValue(date, out var session)
|
||||||
|
? participation.GetBySessionAndStudent(session.Id, match.Id) : null;
|
||||||
|
rows.Add(new NamedUntisAbsenceRowDto(
|
||||||
|
match.Id, match.FullName, date, absence.Reason ?? "",
|
||||||
|
LocalStatusLabel(entry?.Attendance), UntisLessonAbsenceHelper.MapStatus(absence).ToString()));
|
||||||
|
}
|
||||||
|
return new NamedUntisAbsenceResultDto(true, $"{rows.Count} Fehlzeile(n) für {resolvedStudents.Count} Schüler.", rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
|
||||||
|
private static bool TryParseDate(int value, out DateOnly date) =>
|
||||||
|
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||||
|
|
||||||
|
// Eigene, schlanke Beschriftung statt AttendanceDisplay (Views-/ViewModel-Bezug) - Tool-Klassen
|
||||||
|
// unter Services/Mcp sollen nicht von ViewModel-Klassen abhängen (siehe GradeTools).
|
||||||
|
private static string LocalStatusLabel(AttendanceStatus? s) => s switch
|
||||||
|
{
|
||||||
|
null => "kein Status erfasst",
|
||||||
|
AttendanceStatus.Present => "Anwesend",
|
||||||
|
AttendanceStatus.ExcusePending => "Krank (Entschuldigung offen)",
|
||||||
|
AttendanceStatus.Excused => "Krank, entschuldigt",
|
||||||
|
AttendanceStatus.Unexcused => "Krank, unentschuldigt",
|
||||||
|
AttendanceStatus.Late => "Verspätet",
|
||||||
|
AttendanceStatus.LeftDuringClass => "Während des Unterrichts abgängig",
|
||||||
|
_ => s.ToString()!,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -25,7 +25,9 @@ public static class UntisHubActions
|
|||||||
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||||
App.Services.GetRequiredService<IStudentRepository>(),
|
App.Services.GetRequiredService<IStudentRepository>(),
|
||||||
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
||||||
App.Services.GetRequiredService<IParticipationRepository>())
|
App.Services.GetRequiredService<IParticipationRepository>(),
|
||||||
|
App.Services.GetRequiredService<AiPlanningService>(),
|
||||||
|
App.Services.GetRequiredService<AiSettingsService>())
|
||||||
{ StartDate = start.ToDateTime(TimeOnly.MinValue), EndDate = end.ToDateTime(TimeOnly.MinValue) };
|
{ StartDate = start.ToDateTime(TimeOnly.MinValue), EndDate = end.ToDateTime(TimeOnly.MinValue) };
|
||||||
var loaded = TrackLoad(vm, v => v.Busy);
|
var loaded = TrackLoad(vm, v => v.Busy);
|
||||||
await new WebUntisLessonAbsenceComparisonDialog { DataContext = vm }.ShowDialog(owner);
|
await new WebUntisLessonAbsenceComparisonDialog { DataContext = vm }.ShowDialog(owner);
|
||||||
|
|||||||
@@ -46,11 +46,23 @@ public sealed class UntisHubService(
|
|||||||
return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow);
|
return BuildRows(eligibleGroups, jobStates.GetAll(), DateTime.UtcNow);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? summary) =>
|
/// <summary>Der Langzeit-Fehlzeitenabgleich deckt seit Schuljahresbeginn ein Zeitfenster ab, das
|
||||||
|
/// das kurzfristige vollständig einschließt - ohne das hier mitzuziehen bliebe die kurzfristige
|
||||||
|
/// Kadenz trotz erledigtem Langzeit-Abgleich als fällig stehen (Nutzer-Feedback). Umgekehrt deckt
|
||||||
|
/// ein Kurz-Lauf das lange Fenster nicht ab, bleibt also einseitig.
|
||||||
|
public void RecordRun(UntisHubJobKind kind, Guid? groupId, string? summary)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
Save(kind, groupId, now, summary);
|
||||||
|
if (kind == UntisHubJobKind.FehlzeitenLang)
|
||||||
|
Save(UntisHubJobKind.FehlzeitenKurz, groupId, now, "durch Abgleich seit Schuljahresbeginn mit erledigt");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save(UntisHubJobKind kind, Guid? groupId, DateTime at, string? summary) =>
|
||||||
jobStates.Save(new UntisHubJobState
|
jobStates.Save(new UntisHubJobState
|
||||||
{
|
{
|
||||||
Id = jobStates.Get(kind, groupId)?.Id ?? Guid.NewGuid(),
|
Id = jobStates.Get(kind, groupId)?.Id ?? Guid.NewGuid(),
|
||||||
Kind = kind, GroupId = groupId, LastRunAt = DateTime.UtcNow, LastResultSummary = summary,
|
Kind = kind, GroupId = groupId, LastRunAt = at, LastResultSummary = summary,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Reine Entscheidungslogik ohne Repository-Zugriff (gleiches Muster wie
|
/// Reine Entscheidungslogik ohne Repository-Zugriff (gleiches Muster wie
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using LehrerApp.Core.Importing;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aus <c>WebUntisLessonAbsenceComparisonViewModel</c> herausgelöst (Nachtrag: die MCP-Tools in
|
||||||
|
/// <c>UntisComparisonTools</c> brauchen exakt dieselbe Regel für dieselbe Fehlzeile, siehe TODO.md) -
|
||||||
|
/// framework-frei (kein ObservableObject/Avalonia-Bezug), damit beide Aufrufer ohne Duplikat
|
||||||
|
/// garantiert denselben Statusvorschlag berechnen. Eine Abweichung zwischen Dialog und MCP-Tool für
|
||||||
|
/// dieselbe WebUntis-Zeile wäre verwirrender als die eine zusätzliche Indirektion hier.
|
||||||
|
/// </summary>
|
||||||
|
public static class UntisLessonAbsenceHelper
|
||||||
|
{
|
||||||
|
private const int FullLessonMinutes = 45;
|
||||||
|
|
||||||
|
// Der Bericht liefert keinen Entschuldigungstext, nur Minutenwerte, ein Bearbeitet-Datum und die
|
||||||
|
// (laut Schule) über Klammerung der ENr codierte Entscheidung des Klassenlehrers - ENr in
|
||||||
|
// Klammern bedeutet unentschuldigt, ohne Klammern abgeschlossen/entschuldigt. Reihenfolge ist
|
||||||
|
// wichtig: "nach Hause entlassen" zählt immer als vorzeitige Entlassung, unabhängig von der
|
||||||
|
// Dauer; darunter zählt jede Fehlzeit unter einer vollen Stunde (45 Min.) immer als Verspätung
|
||||||
|
// oder sonstiger Teilverlust, nie als komplette Abwesenheit - der Text "Verspätung" allein ist
|
||||||
|
// laut Schule nicht zuverlässig genug, deshalb primär über die Minutenschwelle erkannt.
|
||||||
|
public static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence)
|
||||||
|
{
|
||||||
|
if (IsEarlyRelease(absence)) return AttendanceStatus.LeftDuringClass;
|
||||||
|
if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.Late;
|
||||||
|
if (string.IsNullOrWhiteSpace(absence.HandledOn)) return AttendanceStatus.ExcusePending;
|
||||||
|
if (absence.ExternKey is null) return AttendanceStatus.ExcusePending;
|
||||||
|
return absence.ExternKeyInParentheses ? AttendanceStatus.Unexcused : AttendanceStatus.Excused;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEarlyRelease(UntisLessonAbsenceDto absence) =>
|
||||||
|
absence.Reason?.Contains("entlassen", StringComparison.OrdinalIgnoreCase) == true;
|
||||||
|
|
||||||
|
/// Erste Wahl zum Zuordnen einer WebUntis-Fehlzeile: die externe Schülernummer (ENr), sofern der
|
||||||
|
/// Schüler eine hat (z.B. nicht bei manuell statt per WebUntis-Import angelegten Schülern).
|
||||||
|
public static int? StudentExternKey(Student student)
|
||||||
|
{
|
||||||
|
student.ExternalIds ??= [];
|
||||||
|
return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
|
||||||
|
&& int.TryParse(value, out var key) ? key : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,7 +145,7 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
private void LoadNextLesson(DateOnly today)
|
private void LoadNextLesson(DateOnly today)
|
||||||
{
|
{
|
||||||
var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays))
|
var next = _lessons.GetByGroupAndRange(_groupId, today, today.AddDays(NextLessonLookaheadDays))
|
||||||
.Where(l => l.Status != LessonStatus.Conducted)
|
.Where(l => l.Status is not (LessonStatus.Conducted or LessonStatus.Cancelled))
|
||||||
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0)
|
.OrderBy(l => l.Date).ThenBy(l => l.LessonNumber ?? 0)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
@@ -220,6 +220,7 @@ public partial class GroupOverviewViewModel : ObservableObject
|
|||||||
private void LoadOpenHomeworkCheck(DateOnly today)
|
private void LoadOpenHomeworkCheck(DateOnly today)
|
||||||
{
|
{
|
||||||
var previous = _lessons.GetByGroupAndRange(_groupId, today.AddDays(-HomeworkCheckLookbackDays), today.AddDays(-1))
|
var previous = _lessons.GetByGroupAndRange(_groupId, today.AddDays(-HomeworkCheckLookbackDays), today.AddDays(-1))
|
||||||
|
.Where(l => l.Status != LessonStatus.Cancelled)
|
||||||
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
|
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
|||||||
@@ -376,7 +376,7 @@ public partial class PlanningTabViewModel : ObservableObject
|
|||||||
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
[RelayCommand(CanExecute = nameof(HasSelectedLesson))]
|
||||||
private void AdvanceLessonStatus()
|
private void AdvanceLessonStatus()
|
||||||
{
|
{
|
||||||
if (SelectedLesson is null || SelectedLesson.Model.Status == LessonStatus.Conducted) return;
|
if (SelectedLesson is null || SelectedLesson.Model.Status is LessonStatus.Conducted or LessonStatus.Cancelled) return;
|
||||||
var lesson = SelectedLesson.Model;
|
var lesson = SelectedLesson.Model;
|
||||||
lesson.Status = lesson.Status == LessonStatus.Ready
|
lesson.Status = lesson.Status == LessonStatus.Ready
|
||||||
? LessonStatus.Conducted
|
? LessonStatus.Conducted
|
||||||
@@ -467,8 +467,11 @@ public class UnitSummary
|
|||||||
};
|
};
|
||||||
CompetencyCountLabel = u.Competencies.Count == 0 ? "–" : $"{u.Competencies.Count} Kompetenz(en)";
|
CompetencyCountLabel = u.Competencies.Count == 0 ? "–" : $"{u.Competencies.Count} Kompetenz(en)";
|
||||||
|
|
||||||
TotalCount = lessons.Count;
|
// Ausgefallene Stunden zählen weder als gehalten noch als noch zu haltendes Pensum -
|
||||||
ConductedCount = lessons.Count(l => l.Status == LessonStatus.Conducted);
|
// sie fallen komplett aus dem Fortschritt heraus, statt den Nenner künstlich zu erhöhen.
|
||||||
|
var countableLessons = lessons.Where(l => l.Status != LessonStatus.Cancelled).ToList();
|
||||||
|
TotalCount = countableLessons.Count;
|
||||||
|
ConductedCount = countableLessons.Count(l => l.Status == LessonStatus.Conducted);
|
||||||
ProgressFraction = TotalCount == 0 ? 0 : (double)ConductedCount / TotalCount;
|
ProgressFraction = TotalCount == 0 ? 0 : (double)ConductedCount / TotalCount;
|
||||||
ProgressText = TotalCount == 0 ? "Keine Stunden" : $"{ConductedCount} / {TotalCount} Stunden gehalten";
|
ProgressText = TotalCount == 0 ? "Keine Stunden" : $"{ConductedCount} / {TotalCount} Stunden gehalten";
|
||||||
}
|
}
|
||||||
@@ -507,6 +510,7 @@ public class LessonSummary
|
|||||||
LessonStatus.Draft => "#78909C",
|
LessonStatus.Draft => "#78909C",
|
||||||
LessonStatus.Ready => "#1976D2",
|
LessonStatus.Ready => "#1976D2",
|
||||||
LessonStatus.Conducted => "#43A047",
|
LessonStatus.Conducted => "#43A047",
|
||||||
|
LessonStatus.Cancelled => "#C62828",
|
||||||
_ => "#9E9E9E",
|
_ => "#9E9E9E",
|
||||||
};
|
};
|
||||||
PhaseCountLabel = l.Phases.Count == 0 ? "–" : $"{l.Phases.Count} Phasen";
|
PhaseCountLabel = l.Phases.Count == 0 ? "–" : $"{l.Phases.Count} Phasen";
|
||||||
@@ -543,13 +547,14 @@ public static class UnitStatusDisplay
|
|||||||
|
|
||||||
public static class LessonStatusDisplay
|
public static class LessonStatusDisplay
|
||||||
{
|
{
|
||||||
public static string[] Options { get; } = ["Entwurf", "Geplant", "Bereit", "Durchgeführt"];
|
public static string[] Options { get; } = ["Entwurf", "Geplant", "Bereit", "Durchgeführt", "Ausgefallen"];
|
||||||
|
|
||||||
public static string ToName(LessonStatus s) => s switch
|
public static string ToName(LessonStatus s) => s switch
|
||||||
{
|
{
|
||||||
LessonStatus.Draft => "Entwurf",
|
LessonStatus.Draft => "Entwurf",
|
||||||
LessonStatus.Ready => "Bereit",
|
LessonStatus.Ready => "Bereit",
|
||||||
LessonStatus.Conducted => "Durchgeführt",
|
LessonStatus.Conducted => "Durchgeführt",
|
||||||
|
LessonStatus.Cancelled => "Ausgefallen",
|
||||||
_ => "Geplant",
|
_ => "Geplant",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -558,6 +563,7 @@ public static class LessonStatusDisplay
|
|||||||
"Entwurf" => LessonStatus.Draft,
|
"Entwurf" => LessonStatus.Draft,
|
||||||
"Bereit" => LessonStatus.Ready,
|
"Bereit" => LessonStatus.Ready,
|
||||||
"Durchgeführt" => LessonStatus.Conducted,
|
"Durchgeführt" => LessonStatus.Conducted,
|
||||||
|
"Ausgefallen" => LessonStatus.Cancelled,
|
||||||
_ => LessonStatus.Planned,
|
_ => LessonStatus.Planned,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using LehrerApp.Core.Importing;
|
using LehrerApp.Core.AiPlanning;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
@@ -24,10 +24,38 @@ public partial class WebUntisLessonAbsenceRow : ObservableObject
|
|||||||
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
||||||
public bool CanApply => SessionId is not null;
|
public bool CanApply => SessionId is not null;
|
||||||
|
|
||||||
|
// Nur für den optionalen KI-Statusvorschlag mitgeführt (siehe
|
||||||
|
// WebUntisLessonAbsenceComparisonViewModel.SuggestStatusWithAi) - dieselben Rohsignale, aus
|
||||||
|
// denen MapStatus den TargetStatus berechnet, bewusst OHNE Name/Klasse/Datum, damit die Anfrage
|
||||||
|
// an das KI-Backend personenbezogen leer bleibt.
|
||||||
|
internal int AbsentMinutes { get; init; }
|
||||||
|
internal bool HandledOn { get; init; }
|
||||||
|
internal bool? ExternKeyInParentheses { get; init; }
|
||||||
|
|
||||||
|
// Statusübernahme ist per ComboBox anpassbar (Nutzer-Feedback: der aus WebUntis abgeleitete
|
||||||
|
// TargetStatus war über den reinen Anzeigetext oft nicht eindeutig nachvollziehbar; Ablehnen der
|
||||||
|
// ganzen Zeile und der Status manuell nachtragen war die einzige Korrekturmöglichkeit) - die
|
||||||
|
// ComboBox ist mit TargetStatus vorbelegt, aber vor "Übernehmen" frei änderbar. Bindet wie bei
|
||||||
|
// GradeCategoryDisplay über einen String-Wrapper statt direkt ans Enum (sonst ToString() auf
|
||||||
|
// Englisch). Nur die Stati, die MapStatus tatsächlich liefert bzw. die als Korrektur plausibel
|
||||||
|
// sind (nicht z.B. "Geschwänzt" oder "Suspendiert", die WebUntis hier nie meldet). Internal statt
|
||||||
|
// private, damit SuggestStatusWithAi eine von der KI zurückgegebene Statusangabe dagegen validieren
|
||||||
|
// kann, statt jeden von der KI genannten Enum-Namen blind zu übernehmen.
|
||||||
|
internal static readonly AttendanceStatus[] SelectableStatuses =
|
||||||
|
[
|
||||||
|
AttendanceStatus.ExcusePending, AttendanceStatus.Excused, AttendanceStatus.Unexcused,
|
||||||
|
AttendanceStatus.Late, AttendanceStatus.LeftDuringClass, AttendanceStatus.Present,
|
||||||
|
];
|
||||||
|
public static string[] StatusOptions { get; } = SelectableStatuses.Select(s => AttendanceDisplay.Label(s)).ToArray();
|
||||||
|
|
||||||
[ObservableProperty] private Student? _assignedStudent;
|
[ObservableProperty] private Student? _assignedStudent;
|
||||||
[ObservableProperty] private string _localStatus = "ohne Zuordnung";
|
[ObservableProperty] private string _localStatus = "ohne Zuordnung";
|
||||||
[ObservableProperty] private Guid? _sessionId;
|
[ObservableProperty] private Guid? _sessionId;
|
||||||
[ObservableProperty] private bool _selected;
|
[ObservableProperty] private bool _selected;
|
||||||
|
[ObservableProperty] private string _selectedStatusName = "";
|
||||||
|
|
||||||
|
public AttendanceStatus SelectedStatus =>
|
||||||
|
SelectableStatuses.FirstOrDefault(s => AttendanceDisplay.Label(s) == SelectedStatusName, TargetStatus);
|
||||||
|
|
||||||
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
|
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
|
||||||
}
|
}
|
||||||
@@ -42,6 +70,8 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
private readonly IStudentRepository _students;
|
private readonly IStudentRepository _students;
|
||||||
private readonly IParticipationSessionRepository _sessions;
|
private readonly IParticipationSessionRepository _sessions;
|
||||||
private readonly IParticipationRepository _participation;
|
private readonly IParticipationRepository _participation;
|
||||||
|
private readonly AiPlanningService _ai;
|
||||||
|
private readonly AiSettingsService _aiSettings;
|
||||||
|
|
||||||
private IReadOnlyList<Student> _loadedStudents = [];
|
private IReadOnlyList<Student> _loadedStudents = [];
|
||||||
private IReadOnlyDictionary<DateOnly, ParticipationSession> _loadedSessions =
|
private IReadOnlyDictionary<DateOnly, ParticipationSession> _loadedSessions =
|
||||||
@@ -52,14 +82,15 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
||||||
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
|
[ObservableProperty] private string _status = "Zeitraum wählen und Fehlzeiten laden.";
|
||||||
[ObservableProperty] private bool _busy;
|
[ObservableProperty] private bool _busy;
|
||||||
|
[ObservableProperty] private bool _aiSuggestBusy;
|
||||||
[ObservableProperty] private bool _markUnknownAsPresent;
|
[ObservableProperty] private bool _markUnknownAsPresent;
|
||||||
|
|
||||||
public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
|
public WebUntisLessonAbsenceComparisonViewModel(LearningGroup group, WebUntisIntegrationService untis,
|
||||||
IStudentRepository students, IParticipationSessionRepository sessions,
|
IStudentRepository students, IParticipationSessionRepository sessions,
|
||||||
IParticipationRepository participation)
|
IParticipationRepository participation, AiPlanningService ai, AiSettingsService aiSettings)
|
||||||
{
|
{
|
||||||
_group = group; _untis = untis; _students = students; _sessions = sessions;
|
_group = group; _untis = untis; _students = students; _sessions = sessions;
|
||||||
_participation = participation;
|
_participation = participation; _ai = ai; _aiSettings = aiSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -88,7 +119,7 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
// Erste Wahl: WebUntis-Kennung (ENr). Nicht jeder Schüler hat eine (z.B. manuell statt
|
// Erste Wahl: WebUntis-Kennung (ENr). Nicht jeder Schüler hat eine (z.B. manuell statt
|
||||||
// per WebUntis-Import angelegt) - Fallback über den Namen, aber nur wenn er innerhalb
|
// per WebUntis-Import angelegt) - Fallback über den Namen, aber nur wenn er innerhalb
|
||||||
// der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten.
|
// der Kursmitglieder eindeutig ist, sonst lieber unzugeordnet lassen als raten.
|
||||||
var byKey = courseStudents.Select(student => (Student: student, Key: StudentKey(student)))
|
var byKey = courseStudents.Select(student => (Student: student, Key: UntisLessonAbsenceHelper.StudentExternKey(student)))
|
||||||
.Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
|
.Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
|
||||||
var byName = courseStudents
|
var byName = courseStudents
|
||||||
.SelectMany(student => new[]
|
.SelectMany(student => new[]
|
||||||
@@ -125,13 +156,18 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent)
|
var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent)
|
||||||
? byKeyStudent
|
? byKeyStudent
|
||||||
: byName.GetValueOrDefault(NameKey(absence.StudentName));
|
: byName.GetValueOrDefault(NameKey(absence.StudentName));
|
||||||
|
var targetStatus = MapStatus(absence);
|
||||||
var row = new WebUntisLessonAbsenceRow
|
var row = new WebUntisLessonAbsenceRow
|
||||||
{
|
{
|
||||||
UntisStudentName = absence.StudentName, Date = date!.Value,
|
UntisStudentName = absence.StudentName, Date = date!.Value,
|
||||||
TimeLabel = TimeLabel(absence.StartTime, absence.EndTime),
|
TimeLabel = TimeLabel(absence.StartTime, absence.EndTime),
|
||||||
UntisStatus = DisplayUntisStatus(absence),
|
UntisStatus = DisplayUntisStatus(absence),
|
||||||
TargetStatus = MapStatus(absence), Reason = absence.Reason,
|
TargetStatus = targetStatus, Reason = absence.Reason,
|
||||||
|
AbsentMinutes = absence.AbsentMinutes,
|
||||||
|
HandledOn = !string.IsNullOrWhiteSpace(absence.HandledOn),
|
||||||
|
ExternKeyInParentheses = absence.ExternKey is null ? null : absence.ExternKeyInParentheses,
|
||||||
Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch,
|
Candidates = courseStudents, OnAssignmentChanged = ResolveLocalMatch,
|
||||||
|
SelectedStatusName = AttendanceDisplay.Label(targetStatus),
|
||||||
};
|
};
|
||||||
Rows.Add(row);
|
Rows.Add(row);
|
||||||
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
|
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
|
||||||
@@ -146,6 +182,59 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
finally { Busy = false; }
|
finally { Busy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fragt für alle geladenen, einer lokalen Kursstunde zuordenbaren Zeilen in einer gebündelten
|
||||||
|
/// Anfrage (Kosten/Latenz, siehe AiPlanningService.RequestUntisStatusSuggestionsAsync) einen
|
||||||
|
/// KI-Statusvorschlag ab und setzt ihn nur in der "Übernahme als"-ComboBox vor - Namen, Klasse
|
||||||
|
/// und Datum verlassen dafür nie die App (siehe AiUntisStatusRow), nur die je Zeile rein
|
||||||
|
/// technische Positions-Id sowie die bereits lokal bekannten Rohsignale. Ersetzt nie
|
||||||
|
/// eigenständig einen bestehenden Übernahme-Status ohne Zutun der Lehrkraft - "Markierte
|
||||||
|
/// übernehmen" bleibt der einzige schreibende Schritt.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SuggestStatusWithAi()
|
||||||
|
{
|
||||||
|
var token = _aiSettings.GetToken();
|
||||||
|
if (token is null)
|
||||||
|
{
|
||||||
|
Status = "Nicht angemeldet. Bitte in den Einstellungen bei der KI-Unterstützung anmelden.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var candidates = Rows.Where(x => x.CanApply).ToList();
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
{
|
||||||
|
Status = "Keine Zeilen mit lokaler Kursstunde geladen.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AiSuggestBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var requestRows = candidates.Select((row, i) => new AiUntisStatusRow
|
||||||
|
{
|
||||||
|
Id = i.ToString(), ReasonText = row.Reason ?? "", AbsentMinutes = row.AbsentMinutes,
|
||||||
|
HandledOn = row.HandledOn, ExternKeyInParentheses = row.ExternKeyInParentheses,
|
||||||
|
CurrentGuess = row.TargetStatus.ToString(),
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var suggestions = await _ai.RequestUntisStatusSuggestionsAsync(requestRows, token);
|
||||||
|
var applied = 0;
|
||||||
|
for (var i = 0; i < candidates.Count; i++)
|
||||||
|
{
|
||||||
|
if (!suggestions.TryGetValue(i.ToString(), out var statusName)) continue;
|
||||||
|
if (!Enum.TryParse<AttendanceStatus>(statusName, out var status)) continue;
|
||||||
|
if (!WebUntisLessonAbsenceRow.SelectableStatuses.Contains(status)) continue;
|
||||||
|
candidates[i].SelectedStatusName = AttendanceDisplay.Label(status);
|
||||||
|
applied++;
|
||||||
|
}
|
||||||
|
Status = suggestions.Count == 0
|
||||||
|
? "Die KI hat keinen verwertbaren Vorschlag geliefert, die bisherige Vorbelegung bleibt unverändert."
|
||||||
|
: $"KI-Vorschlag für {applied} von {candidates.Count} Zeilen in \"Übernahme als\" vorbelegt - bitte prüfen.";
|
||||||
|
}
|
||||||
|
catch (AiBackendException ex) { Status = ex.Message; }
|
||||||
|
finally { AiSuggestBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Apply()
|
private void Apply()
|
||||||
{
|
{
|
||||||
@@ -155,7 +244,7 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
var studentId = row.AssignedStudent!.Id;
|
var studentId = row.AssignedStudent!.Id;
|
||||||
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId)
|
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId)
|
||||||
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId };
|
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId };
|
||||||
entry.Attendance = row.TargetStatus;
|
entry.Attendance = row.SelectedStatus;
|
||||||
entry.UpdatedAt = DateTime.UtcNow;
|
entry.UpdatedAt = DateTime.UtcNow;
|
||||||
_participation.Save(entry);
|
_participation.Save(entry);
|
||||||
}
|
}
|
||||||
@@ -192,38 +281,16 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
|
|||||||
return filled;
|
return filled;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int? StudentKey(Student student)
|
|
||||||
{
|
|
||||||
student.ExternalIds ??= [];
|
|
||||||
return student.ExternalIds.TryGetValue(StudentImportFormats.MasterDataCsv.Value, out var value)
|
|
||||||
&& int.TryParse(value, out var key) ? key : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Groß-/Kleinschreibung, Leerraum und - da die tatsächliche WebUntis-Reihenfolge nicht
|
// Groß-/Kleinschreibung, Leerraum und - da die tatsächliche WebUntis-Reihenfolge nicht
|
||||||
// dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen
|
// dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen
|
||||||
// werden beim Aufbau von `byName` registriert; hier wird nur normalisiert.
|
// werden beim Aufbau von `byName` registriert; hier wird nur normalisiert.
|
||||||
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
|
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
private const int FullLessonMinutes = 45;
|
// MapStatus/StudentKey leben jetzt in UntisLessonAbsenceHelper (framework-frei), damit
|
||||||
|
// UntisComparisonTools (MCP) exakt dieselbe Regel verwendet statt eines eigenen Duplikats, das
|
||||||
// Der Bericht liefert keinen Entschuldigungstext, nur Minutenwerte, ein Bearbeitet-Datum und die
|
// aus dem Tritt geraten könnte.
|
||||||
// (laut Schule) über Klammerung der ENr codierte Entscheidung des Klassenlehrers - ENr in
|
private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence) =>
|
||||||
// Klammern bedeutet unentschuldigt, ohne Klammern abgeschlossen/entschuldigt. Reihenfolge ist
|
UntisLessonAbsenceHelper.MapStatus(absence);
|
||||||
// wichtig: "nach Hause entlassen" zählt immer als vorzeitige Entlassung, unabhängig von der
|
|
||||||
// Dauer; darunter zählt jede Fehlzeit unter einer vollen Stunde (45 Min.) immer als Verspätung
|
|
||||||
// oder sonstiger Teilverlust, nie als komplette Abwesenheit - der Text "Verspätung" allein ist
|
|
||||||
// laut Schule nicht zuverlässig genug, deshalb primär über die Minutenschwelle erkannt.
|
|
||||||
private static AttendanceStatus MapStatus(UntisLessonAbsenceDto absence)
|
|
||||||
{
|
|
||||||
if (IsEarlyRelease(absence)) return AttendanceStatus.LeftDuringClass;
|
|
||||||
if (absence.AbsentMinutes < FullLessonMinutes) return AttendanceStatus.Late;
|
|
||||||
if (string.IsNullOrWhiteSpace(absence.HandledOn)) return AttendanceStatus.ExcusePending;
|
|
||||||
if (absence.ExternKey is null) return AttendanceStatus.ExcusePending;
|
|
||||||
return absence.ExternKeyInParentheses ? AttendanceStatus.Unexcused : AttendanceStatus.Excused;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsEarlyRelease(UntisLessonAbsenceDto absence) =>
|
|
||||||
absence.Reason?.Contains("entlassen", StringComparison.OrdinalIgnoreCase) == true;
|
|
||||||
|
|
||||||
// Nutzt dieselben deutschen Bezeichnungen wie die reguläre Mitarbeitserfassung
|
// Nutzt dieselben deutschen Bezeichnungen wie die reguläre Mitarbeitserfassung
|
||||||
// (AttendanceDisplay.Label), statt eigene Statustexte zu erfinden.
|
// (AttendanceDisplay.Label), statt eigene Statustexte zu erfinden.
|
||||||
|
|||||||
@@ -612,6 +612,7 @@ public partial class TimetableViewModel : ObservableObject
|
|||||||
private bool HasUnhandledHomework(Guid groupId, DateOnly date)
|
private bool HasUnhandledHomework(Guid groupId, DateOnly date)
|
||||||
{
|
{
|
||||||
var previousLesson = _lessons.GetByGroupAndRange(groupId, date.AddDays(-120), date.AddDays(-1))
|
var previousLesson = _lessons.GetByGroupAndRange(groupId, date.AddDays(-120), date.AddDays(-1))
|
||||||
|
.Where(l => l.Status != LessonStatus.Cancelled)
|
||||||
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
|
.OrderByDescending(l => l.Date).ThenByDescending(l => l.LessonNumber ?? 0)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
if (previousLesson is null || string.IsNullOrWhiteSpace(previousLesson.Homework)) return false;
|
if (previousLesson is null || string.IsNullOrWhiteSpace(previousLesson.Homework)) return false;
|
||||||
|
|||||||
@@ -161,7 +161,9 @@ public partial class GroupDetailView : UserControl
|
|||||||
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||||
App.Services.GetRequiredService<IStudentRepository>(),
|
App.Services.GetRequiredService<IStudentRepository>(),
|
||||||
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
||||||
App.Services.GetRequiredService<IParticipationRepository>());
|
App.Services.GetRequiredService<IParticipationRepository>(),
|
||||||
|
App.Services.GetRequiredService<AiPlanningService>(),
|
||||||
|
App.Services.GetRequiredService<AiSettingsService>());
|
||||||
await new WebUntisLessonAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
|
await new WebUntisLessonAbsenceComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
|
||||||
vm.ParticipationTab.RefreshCurrentGrid();
|
vm.ParticipationTab.RefreshCurrentGrid();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||||
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisLessonAbsenceComparisonDialog"
|
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisLessonAbsenceComparisonDialog"
|
||||||
x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel"
|
x:DataType="vm:WebUntisLessonAbsenceComparisonViewModel"
|
||||||
Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1000" Height="640"
|
Title="Fehlzeiten je Unterricht mit WebUntis abgleichen" Width="1150" Height="640"
|
||||||
MinWidth="800" MinHeight="450" WindowStartupLocation="CenterOwner">
|
MinWidth="900" MinHeight="450" WindowStartupLocation="CenterOwner">
|
||||||
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto,Auto" Margin="24" RowSpacing="8">
|
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto,Auto" Margin="24" RowSpacing="8">
|
||||||
<StackPanel Grid.Row="0" Spacing="4">
|
<StackPanel Grid.Row="0" Spacing="4">
|
||||||
<TextBlock Text="Fehlzeiten je Unterricht mit WebUntis abgleichen" Classes="dialogtitle"/>
|
<TextBlock Text="Fehlzeiten je Unterricht mit WebUntis abgleichen" Classes="dialogtitle"/>
|
||||||
<TextBlock Text="Nur markierte Zeilen mit einer vorhandenen lokalen Kursstunde werden übernommen. Zeilen ohne automatische Zuordnung bitte manuell einem Kursmitglied zuweisen."
|
<TextBlock Text="Nur markierte Zeilen mit einer vorhandenen lokalen Kursstunde werden übernommen. Zeilen ohne automatische Zuordnung bitte manuell einem Kursmitglied zuweisen. „Übernahme als“ ist mit dem aus WebUntis abgeleiteten Status vorbelegt, aber vor dem Übernehmen änderbar."
|
||||||
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
|
FontSize="12" Opacity="0.65" TextWrapping="Wrap"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
|
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
|
||||||
@@ -17,20 +17,24 @@
|
|||||||
<TextBlock Text="bis" VerticalAlignment="Center"/>
|
<TextBlock Text="bis" VerticalAlignment="Center"/>
|
||||||
<CalendarDatePicker SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
|
<CalendarDatePicker SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
|
||||||
<Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
<Button Content="Fehlzeiten laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||||
|
<Button Content="KI-Vorschlag für Übernahme laden" Command="{Binding SuggestStatusWithAiCommand}"
|
||||||
|
IsEnabled="{Binding !AiSuggestBusy}"
|
||||||
|
ToolTip.Tip="Schickt für jede geladene Zeile nur eine technische Positions-Id sowie Grund/Minuten/Bearbeitet-Kennzeichen ans KI-Backend - nie Namen, Klasse oder Datum. Setzt nur die "Übernahme als"-Auswahl vor, übernimmt nichts von selbst."/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="4,0">
|
<Grid Grid.Row="2" ColumnDefinitions="Auto,1.1*,1.1*,80,80,1.1*,1.1*,1*" ColumnSpacing="8" Margin="4,0">
|
||||||
<TextBlock Grid.Column="1" Text="Name (WebUntis)" FontSize="11" Opacity="0.6"/>
|
<TextBlock Grid.Column="1" Text="Name (WebUntis)" FontSize="11" Opacity="0.6"/>
|
||||||
<TextBlock Grid.Column="2" Text="Zuordnung" FontSize="11" Opacity="0.6"/>
|
<TextBlock Grid.Column="2" Text="Zuordnung" FontSize="11" Opacity="0.6"/>
|
||||||
<TextBlock Grid.Column="3" Text="Datum" FontSize="11" Opacity="0.6"/>
|
<TextBlock Grid.Column="3" Text="Datum" FontSize="11" Opacity="0.6"/>
|
||||||
<TextBlock Grid.Column="4" Text="Zeit" FontSize="11" Opacity="0.6"/>
|
<TextBlock Grid.Column="4" Text="Zeit" FontSize="11" Opacity="0.6"/>
|
||||||
<TextBlock Grid.Column="5" Text="WebUntis-Status" FontSize="11" Opacity="0.6"/>
|
<TextBlock Grid.Column="5" Text="WebUntis-Status" FontSize="11" Opacity="0.6"/>
|
||||||
<TextBlock Grid.Column="6" Text="Lokaler Status" FontSize="11" Opacity="0.6"/>
|
<TextBlock Grid.Column="6" Text="Übernahme als" FontSize="11" Opacity="0.6"/>
|
||||||
|
<TextBlock Grid.Column="7" Text="Lokaler Status" FontSize="11" Opacity="0.6"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<ScrollViewer Grid.Row="3">
|
<ScrollViewer Grid.Row="3">
|
||||||
<ItemsControl ItemsSource="{Binding Rows}">
|
<ItemsControl ItemsSource="{Binding Rows}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:WebUntisLessonAbsenceRow">
|
<DataTemplate x:DataType="vm:WebUntisLessonAbsenceRow">
|
||||||
<Grid ColumnDefinitions="Auto,1.2*,1.2*,80,80,1.1*,1.1*" ColumnSpacing="8" Margin="0,3">
|
<Grid ColumnDefinitions="Auto,1.1*,1.1*,80,80,1.1*,1.1*,1*" ColumnSpacing="8" Margin="0,3">
|
||||||
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
|
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
|
||||||
<TextBlock Grid.Column="1" Text="{Binding UntisStudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
<TextBlock Grid.Column="1" Text="{Binding UntisStudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
<ComboBox Grid.Column="2" ItemsSource="{Binding Candidates}" SelectedItem="{Binding AssignedStudent}"
|
<ComboBox Grid.Column="2" ItemsSource="{Binding Candidates}" SelectedItem="{Binding AssignedStudent}"
|
||||||
@@ -39,7 +43,9 @@
|
|||||||
<TextBlock Grid.Column="3" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
|
<TextBlock Grid.Column="3" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
|
||||||
<TextBlock Grid.Column="4" Text="{Binding TimeLabel}" VerticalAlignment="Center"/>
|
<TextBlock Grid.Column="4" Text="{Binding TimeLabel}" VerticalAlignment="Center"/>
|
||||||
<TextBlock Grid.Column="5" Text="{Binding UntisStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
<TextBlock Grid.Column="5" Text="{Binding UntisStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||||
<TextBlock Grid.Column="6" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
<ComboBox Grid.Column="6" ItemsSource="{Binding StatusOptions}" SelectedItem="{Binding SelectedStatusName}"
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
<TextBlock Grid.Column="7" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Mcp.McpConfirmDialog"
|
||||||
|
x:CompileBindings="False"
|
||||||
|
Title="{Binding Title}"
|
||||||
|
Width="520" MinHeight="240" SizeToContent="Height"
|
||||||
|
CanResize="True" WindowStartupLocation="CenterOwner"
|
||||||
|
Topmost="True" ShowInTaskbar="True">
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||||
|
<Border Grid.Row="0" Background="#FFF4E5" BorderBrush="#D97706" BorderThickness="0,0,0,3" Padding="24,16">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Vorschlag deines KI-Assistenten — bitte prüfen" FontSize="12" FontWeight="SemiBold" Foreground="#8A5A00"/>
|
||||||
|
<TextBlock Text="{Binding Title}" FontSize="20" FontWeight="Bold" TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<ScrollViewer Grid.Row="1" MaxHeight="380" Margin="24,18">
|
||||||
|
<TextBlock Text="{Binding Message}" TextWrapping="Wrap" FontSize="14" LineHeight="20"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
<StackPanel Grid.Row="2" Margin="24,0,24,14" Spacing="6" IsVisible="{Binding AllowSessionTrust}">
|
||||||
|
<CheckBox x:Name="TrustOperationCheck" FontSize="12"
|
||||||
|
Content="Diese Art von Aktion für den Rest der Sitzung nicht mehr nachfragen"/>
|
||||||
|
<CheckBox x:Name="TrustAllCheck" FontSize="12"
|
||||||
|
Content="Alle KI-Aktionen für den Rest der Sitzung nicht mehr nachfragen"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Grid Grid.Row="3" ColumnDefinitions="*,10,*" Margin="24,0,24,20">
|
||||||
|
<Button Grid.Column="0" Content="Ablehnen" HorizontalAlignment="Stretch" Padding="0,12" FontSize="14" Click="OnCancel"/>
|
||||||
|
<Button Grid.Column="2" Content="{Binding ConfirmText}" HorizontalAlignment="Stretch" Padding="0,12" FontSize="14"
|
||||||
|
FontWeight="SemiBold" Background="#D97706" Foreground="White" Click="OnConfirm"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Views.Mcp;
|
||||||
|
|
||||||
|
public partial class McpConfirmDialog : Window
|
||||||
|
{
|
||||||
|
public McpConfirmDialog() => InitializeComponent();
|
||||||
|
|
||||||
|
private void OnConfirm(object? sender, RoutedEventArgs e) =>
|
||||||
|
Close(new McpConfirmDialogResult(true, TrustOperationCheck.IsChecked == true, TrustAllCheck.IsChecked == true));
|
||||||
|
|
||||||
|
// Checkbox-Zustand wird beim Ablehnen bewusst ignoriert (siehe McpConfirmDialogResult).
|
||||||
|
private void OnCancel(object? sender, RoutedEventArgs e) => Close(McpConfirmDialogResult.Rejected);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace LehrerApp.Desktop.Views.Mcp;
|
||||||
|
|
||||||
|
/// <summary>DataContext für <see cref="McpConfirmDialog"/> — bewusst ein eigener, auffälligerer
|
||||||
|
/// Dialog statt Wiederverwendung von <see cref="Views.Shared.ConfirmDialog"/>: Letzterer wird an
|
||||||
|
/// vielen Stellen der App für routinemäßige Bestätigungen verwendet, eine Änderung an dessen
|
||||||
|
/// Größe/Optik hätte all diese Stellen mitbetroffen. Ein von der KI vorgeschlagener Schreibzugriff
|
||||||
|
/// soll sich dagegen bewusst absetzen (Nutzer-Feedback: der ursprüngliche gemeinsame Dialog fiel zu
|
||||||
|
/// wenig auf, wenn LehrerApp gerade nicht im Vordergrund war).</summary>
|
||||||
|
public class McpConfirmDialogInfo
|
||||||
|
{
|
||||||
|
public string Title { get; init; } = "";
|
||||||
|
public string Message { get; init; } = "";
|
||||||
|
public string ConfirmText { get; init; } = "Übernehmen";
|
||||||
|
|
||||||
|
/// <summary>False blendet beide "nicht mehr nachfragen"-Kontrollkästchen aus (Nutzer-Entscheidung
|
||||||
|
/// zu <c>get_named_untis_absence_pattern</c>: eine namentliche Fehlzeitenauskunft muss jedes Mal
|
||||||
|
/// einzeln bestätigt werden) - AvaloniaMcpConfirmationService ignoriert eine Sitzungsfreigabe für
|
||||||
|
/// diesen Aufruf ohnehin serverseitig, das Ausblenden verhindert zusätzlich die falsche
|
||||||
|
/// Erwartung, ein angekreuztes Kästchen hätte hier irgendeine Wirkung.</summary>
|
||||||
|
public bool AllowSessionTrust { get; init; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace LehrerApp.Desktop.Views.Mcp;
|
||||||
|
|
||||||
|
/// <summary>Ergebnis von <see cref="McpConfirmDialog"/>. <see cref="TrustOperation"/>/
|
||||||
|
/// <see cref="TrustAll"/> sind nur bei <see cref="Approved"/>=true aussagekräftig — beim Ablehnen
|
||||||
|
/// werden sie ignoriert (siehe McpConfirmDialog.axaml.cs), Ablehnen + "nicht mehr nachfragen" wäre
|
||||||
|
/// widersprüchlich.</summary>
|
||||||
|
public record McpConfirmDialogResult(bool Approved, bool TrustOperation, bool TrustAll)
|
||||||
|
{
|
||||||
|
public static McpConfirmDialogResult Rejected { get; } = new(false, false, false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using LehrerApp.Core.Mcp;
|
||||||
|
|
||||||
|
namespace LehrerApp.McpBridge;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unterscheidet an jeder Stelle, an der die Bridge versucht, sich mit LehrerApp.Desktop zu
|
||||||
|
/// verbinden, zwischen dem harmlosen Regelfall "LehrerApp läuft gerade nicht" und einem echten
|
||||||
|
/// Verbindungsfehler. <see cref="NamedPipeClientStream.ConnectAsync"/> wirft bei einer lokalen
|
||||||
|
/// Named Pipe (kein Netzwerk, kein Handshake) genau dann eine <see cref="TimeoutException"/>, wenn
|
||||||
|
/// innerhalb der Frist kein Prozess auf den Pipe-Namen lauschte - praktisch immer, weil
|
||||||
|
/// LehrerApp.Desktop nicht läuft oder der MCP-Server dort in den Einstellungen nicht aktiviert ist.
|
||||||
|
/// Jede andere Exception (z.B. <see cref="UnauthorizedAccessException"/> bei einem Rechte-/
|
||||||
|
/// ACL-Mismatch zwischen den Prozessen) deutet auf ein echtes Konfigurationsproblem hin.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ConnectionDiagnostics
|
||||||
|
{
|
||||||
|
public const int NotRunningErrorCode = -32001;
|
||||||
|
public const int UnexpectedErrorCode = -32002;
|
||||||
|
|
||||||
|
public static (int Code, string ClientMessage, string StderrLine) Describe(Exception ex) => ex switch
|
||||||
|
{
|
||||||
|
TimeoutException => (
|
||||||
|
NotRunningErrorCode,
|
||||||
|
"LehrerApp läuft nicht oder der MCP-Server ist in den Einstellungen nicht aktiviert.",
|
||||||
|
"LehrerApp.McpBridge: Kein Kontakt zu LehrerApp (Pipe „" + McpPipeConstants.PipeName +
|
||||||
|
"“ nicht erreichbar). Das ist normal, wenn LehrerApp.Desktop gerade nicht läuft oder der " +
|
||||||
|
"MCP-Server dort in den Einstellungen nicht aktiviert ist - kein Hinweis auf eine " +
|
||||||
|
"fehlerhafte Bridge-/Client-Konfiguration."),
|
||||||
|
_ => (
|
||||||
|
UnexpectedErrorCode,
|
||||||
|
$"Unerwarteter Verbindungsfehler ({ex.GetType().Name}): {ex.Message}",
|
||||||
|
$"LehrerApp.McpBridge: Unerwarteter Fehler beim Verbindungsaufbau zu LehrerApp " +
|
||||||
|
$"({ex.GetType().Name}: {ex.Message}). Das ist vermutlich KEIN einfaches \"LehrerApp " +
|
||||||
|
"läuft noch nicht\", sondern deutet auf ein Konfigurations- oder Rechteproblem hin."),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,5 +5,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
|
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
|
||||||
|
<PackageReference Include="ModelContextProtocol.Core" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,84 +1,45 @@
|
|||||||
using System.IO.Pipes;
|
using LehrerApp.McpBridge;
|
||||||
using System.Text.Json;
|
using ModelContextProtocol.Protocol;
|
||||||
using System.Text.Json.Nodes;
|
using ModelContextProtocol.Server;
|
||||||
using LehrerApp.Core.Mcp;
|
|
||||||
|
|
||||||
// Zustandsloser Bridge-Prozess: reicht die vom KI-Client über stdin/stdout gesprochene
|
// Resilienter Bridge-Prozess (Nutzer-Feedback): meldet sich gegenüber dem KI-Client (z.B. Claude
|
||||||
// JSON-RPC-Verbindung unverändert an die Named Pipe des laufenden LehrerApp-Hauptprozesses durch.
|
// Desktop) IMMER erfolgreich als MCP-Server, unabhängig davon, ob LehrerApp.Desktop beim Start
|
||||||
// Kein eigenes JSON-RPC-Verständnis nötig, außer im Fehlerfall (siehe unten) — stdout ist
|
// dieses Prozesses bereits läuft - Claude Desktop startet oft per Windows-Autostart, bevor der
|
||||||
// ausschließlich für den durchgereichten Protokollstrom reserviert, jede Diagnose geht nach stderr.
|
// Nutzer LehrerApp geöffnet hat. Die eigentliche Weiterleitung an den echten Server sowie das
|
||||||
|
// Nachladen der echten Werkzeugliste, sobald LehrerApp erreichbar ist, übernimmt
|
||||||
|
// RealServerBridge - Details und Begründung siehe dort.
|
||||||
|
|
||||||
const int ConnectTimeoutMs = 3000;
|
var bridge = new RealServerBridge();
|
||||||
|
await bridge.TryReconnectAsync(CancellationToken.None);
|
||||||
|
bridge.EnsureFallbackTool();
|
||||||
|
|
||||||
await using var pipe = new NamedPipeClientStream(
|
using var cts = new CancellationTokenSource();
|
||||||
".", McpPipeConstants.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
|
var reconnectLoop = bridge.RunBackgroundReconnectLoopAsync(TimeSpan.FromSeconds(5), cts.Token);
|
||||||
|
|
||||||
try
|
var serverOptions = new McpServerOptions
|
||||||
{
|
{
|
||||||
await pipe.ConnectAsync(ConnectTimeoutMs);
|
ServerInfo = new Implementation { Name = "LehrerApp", Version = "1.0.0" },
|
||||||
}
|
// ListChanged = true: Voraussetzung dafür, dass Claude Desktop reagiert, wenn RealServerBridge
|
||||||
catch (Exception ex)
|
// das Status-Werkzeug nachträglich durch die echte Werkzeugliste ersetzt (oder umgekehrt, falls
|
||||||
{
|
// LehrerApp während der Sitzung beendet wird).
|
||||||
await Console.Error.WriteLineAsync(
|
Capabilities = new ServerCapabilities { Tools = new ToolsCapability { ListChanged = true } },
|
||||||
$"LehrerApp.McpBridge: Verbindung zu LehrerApp fehlgeschlagen ({ex.Message}). " +
|
ToolCollection = bridge.Tools,
|
||||||
"Läuft die App und ist der MCP-Server in den Einstellungen aktiviert?");
|
};
|
||||||
await RespondWithConnectionErrorAsync();
|
|
||||||
Environment.Exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Console.Error.WriteLineAsync("LehrerApp.McpBridge: verbunden.");
|
|
||||||
|
|
||||||
await using var stdin = Console.OpenStandardInput();
|
await using var stdin = Console.OpenStandardInput();
|
||||||
await using var stdout = Console.OpenStandardOutput();
|
await using var stdout = Console.OpenStandardOutput();
|
||||||
|
await using var transport = new StreamServerTransport(stdin, stdout, "LehrerApp.McpBridge");
|
||||||
|
await using var server = McpServer.Create(transport, serverOptions, loggerFactory: null, serviceProvider: null);
|
||||||
|
|
||||||
var toApp = stdin.CopyToAsync(pipe);
|
|
||||||
var toClient = pipe.CopyToAsync(stdout);
|
|
||||||
|
|
||||||
// Sobald eine Richtung endet (App beendet die Verbindung, oder der KI-Client schließt stdin),
|
|
||||||
// ist die Session vorbei — die andere Kopie hängt sonst an einem offenen Handle.
|
|
||||||
await Task.WhenAny(toApp, toClient);
|
|
||||||
|
|
||||||
// Der Verbindungsaufbau ist fehlgeschlagen, bevor irgendetwas an die App durchgereicht wurde. Der
|
|
||||||
// MCP-Client wartet zu diesem Zeitpunkt bereits auf eine Antwort auf seine erste Anfrage
|
|
||||||
// ("initialize") — eine korrekte JSON-RPC-Fehlerantwort braucht deren "id", also wird diese eine
|
|
||||||
// Zeile noch selbst gelesen und beantwortet, statt den Prozess kommentarlos zu beenden.
|
|
||||||
static async Task RespondWithConnectionErrorAsync()
|
|
||||||
{
|
|
||||||
string? line;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
line = await Console.In.ReadLineAsync();
|
// Läuft, bis der KI-Client stdin schließt (Sitzungsende) - danach ist nichts mehr zu tun.
|
||||||
|
await server.RunAsync(cts.Token);
|
||||||
}
|
}
|
||||||
catch
|
finally
|
||||||
{
|
{
|
||||||
return;
|
await cts.CancelAsync();
|
||||||
}
|
try { await reconnectLoop; }
|
||||||
if (string.IsNullOrWhiteSpace(line)) return;
|
catch { /* Beenden über Cancellation ist der Normalfall hier */ }
|
||||||
|
await bridge.DisposeAsync();
|
||||||
JsonNode? requestId = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
requestId = JsonNode.Parse(line)?["id"];
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
// Keine gültige JSON-RPC-Nachricht - ohne "id" ist keine korrekte Fehlerantwort möglich,
|
|
||||||
// der Prozess beendet sich dann einfach mit einem Fehler-Exitcode.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (requestId is null) return;
|
|
||||||
|
|
||||||
var response = new JsonObject
|
|
||||||
{
|
|
||||||
["jsonrpc"] = "2.0",
|
|
||||||
["id"] = requestId.DeepClone(),
|
|
||||||
["error"] = new JsonObject
|
|
||||||
{
|
|
||||||
["code"] = -32001,
|
|
||||||
["message"] = "LehrerApp läuft nicht oder der MCP-Server ist in den Einstellungen nicht aktiviert.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await Console.Out.WriteLineAsync(response.ToJsonString());
|
|
||||||
await Console.Out.FlushAsync();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace LehrerApp.McpBridge;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reicht einen von LehrerApp.Desktop tatsächlich angebotenen MCP-Tool-Aufruf 1:1 an den echten
|
||||||
|
/// Server weiter - Name, Beschreibung und Eingabeschema (<see cref="ProtocolTool"/>) stammen
|
||||||
|
/// unverändert von dort (siehe <see cref="RealServerBridge"/>). Die Bridge muss dadurch kein
|
||||||
|
/// eigenes Wissen über einzelne LehrerApp-Werkzeuge/-Parameter pflegen; ändert sich ein Tool in
|
||||||
|
/// LehrerApp.Desktop, übernimmt die Bridge das beim nächsten Verbindungsaufbau automatisch.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ProxyMcpServerTool(Tool protocolTool, RealServerBridge bridge) : McpServerTool
|
||||||
|
{
|
||||||
|
public override Tool ProtocolTool { get; } = protocolTool;
|
||||||
|
public override IReadOnlyList<object> Metadata { get; } = [];
|
||||||
|
|
||||||
|
public override ValueTask<CallToolResult> InvokeAsync(
|
||||||
|
RequestContext<CallToolRequestParams> request, CancellationToken cancellationToken) =>
|
||||||
|
new(bridge.CallRealToolAsync(request.Params, cancellationToken));
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using System.IO.Pipes;
|
||||||
|
using LehrerApp.Core.Mcp;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace LehrerApp.McpBridge;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hält die eigentliche Verbindung zu LehrerApp.Desktop als echter MCP-Client über die Named Pipe
|
||||||
|
/// (dasselbe ModelContextProtocol-SDK, das der Server in LehrerApp.Desktop nutzt, siehe
|
||||||
|
/// McpServerHostedService) und spiegelt dessen aktuelle Werkzeugliste 1:1 in <see cref="Tools"/> -
|
||||||
|
/// der Werkzeugsammlung, die der Bridge-eigene MCP-Server gegenüber Claude Desktop anbietet.
|
||||||
|
///
|
||||||
|
/// Kernidee (Nutzer-Feedback): ein MCP-Client wie Claude Desktop führt den Initialize-Handshake nur
|
||||||
|
/// einmal beim Start des Bridge-Prozesses durch und markiert den ganzen Server als fehlgeschlagen
|
||||||
|
/// (und deaktiviert ihn für die Sitzung), wenn dieser Handshake nicht klappt - typischerweise, weil
|
||||||
|
/// LehrerApp.Desktop beim Windows-Systemstart noch nicht läuft (Claude Desktop startet oft per
|
||||||
|
/// Autostart, LehrerApp nicht). Diese Klasse sorgt dafür, dass der Handshake IMMER klappt: ist
|
||||||
|
/// LehrerApp nicht erreichbar, bietet die Bridge statt der echten Werkzeuge nur ein einzelnes
|
||||||
|
/// Status-Werkzeug (<see cref="StatusMcpServerTool"/>) an, das auf Wunsch erneut verbindet und den
|
||||||
|
/// aktuellen Stand erklärt - der Assistent kann dem Nutzer dadurch mitteilen, dass LehrerApp
|
||||||
|
/// gestartet werden muss, statt dass der ganze MCP-Server kommentarlos verschwindet. Ein
|
||||||
|
/// Hintergrund-Loop (<see cref="RunBackgroundReconnectLoopAsync"/>) versucht unabhängig davon
|
||||||
|
/// regelmäßig, die echte Verbindung wiederherzustellen - sobald das klappt, ersetzt <see cref="Tools"/>
|
||||||
|
/// das Status-Werkzeug durch die echte, tagesaktuelle Werkzeugliste, und das SDK benachrichtigt
|
||||||
|
/// Claude Desktop automatisch per "notifications/tools/list_changed"
|
||||||
|
/// (<see cref="McpServerPrimitiveCollection{T}.DeferChangedEvents"/>).
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class RealServerBridge : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private const int ConnectTimeoutMs = 3000;
|
||||||
|
|
||||||
|
public McpServerPrimitiveCollection<McpServerTool> Tools { get; } = new();
|
||||||
|
|
||||||
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
private McpClient? _client;
|
||||||
|
private NamedPipeClientStream? _pipe;
|
||||||
|
|
||||||
|
/// <summary>Reicht einen Tool-Aufruf an die echte Verbindung weiter (aufgerufen aus
|
||||||
|
/// <see cref="ProxyMcpServerTool"/>). Bricht die Verbindung erst hier ab, statt sie proaktiv vor
|
||||||
|
/// jedem Aufruf zu prüfen - ein <see cref="ProxyMcpServerTool"/> existiert per Konstruktion nur,
|
||||||
|
/// solange <see cref="_client"/> zuletzt verbunden war, ein zusätzlicher Verbindungsversuch davor
|
||||||
|
/// wäre unnötige Latenz im Erfolgsfall (dem weit überwiegenden Normalfall).</summary>
|
||||||
|
public async Task<CallToolResult> CallRealToolAsync(CallToolRequestParams requestParams, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var client = _client;
|
||||||
|
if (client is null)
|
||||||
|
{
|
||||||
|
EnsureFallbackTool();
|
||||||
|
return ErrorResult("LehrerApp läuft nicht oder der MCP-Server ist in den Einstellungen nicht aktiviert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await client.CallToolAsync(requestParams, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
var (_, message, stderrLine) = ConnectionDiagnostics.Describe(ex);
|
||||||
|
await Console.Error.WriteLineAsync(stderrLine);
|
||||||
|
await DisconnectAsync();
|
||||||
|
return ErrorResult("Verbindung zu LehrerApp während des Aufrufs verloren. " + message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Versucht, sofern noch nicht verbunden, jetzt sofort eine Verbindung aufzubauen -
|
||||||
|
/// aufgerufen sowohl beim Start der Bridge als auch aus <see cref="StatusMcpServerTool"/>
|
||||||
|
/// (expliziter Nutzer-/Assistenten-Wunsch, jetzt nachzusehen) und aus dem Hintergrund-Loop.</summary>
|
||||||
|
public async Task<bool> TryReconnectAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_client is not null) return true;
|
||||||
|
await _gate.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_client is not null) return true; // ein anderer Aufrufer war währenddessen schneller
|
||||||
|
return await ConnectCoreAsync(ct);
|
||||||
|
}
|
||||||
|
finally { _gate.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> ConnectCoreAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var pipe = new NamedPipeClientStream(
|
||||||
|
".", McpPipeConstants.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await pipe.ConnectAsync(ConnectTimeoutMs, ct);
|
||||||
|
var transport = new StreamClientTransport(pipe, pipe, loggerFactory: null);
|
||||||
|
var client = await McpClient.CreateAsync(
|
||||||
|
transport,
|
||||||
|
new McpClientOptions { ClientInfo = new Implementation { Name = "LehrerApp.McpBridge", Version = "1.0.0" } },
|
||||||
|
loggerFactory: null, ct);
|
||||||
|
var tools = await client.ListToolsAsync(cancellationToken: ct);
|
||||||
|
|
||||||
|
using (Tools.DeferChangedEvents())
|
||||||
|
{
|
||||||
|
Tools.Clear();
|
||||||
|
foreach (var tool in tools)
|
||||||
|
Tools.Add(new ProxyMcpServerTool(tool.ProtocolTool, this));
|
||||||
|
}
|
||||||
|
|
||||||
|
_client = client;
|
||||||
|
_pipe = pipe;
|
||||||
|
await Console.Error.WriteLineAsync(
|
||||||
|
$"LehrerApp.McpBridge: mit LehrerApp verbunden, {tools.Count} Werkzeug(e) übernommen.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
await pipe.DisposeAsync();
|
||||||
|
var (_, _, stderrLine) = ConnectionDiagnostics.Describe(ex);
|
||||||
|
await Console.Error.WriteLineAsync(stderrLine);
|
||||||
|
EnsureFallbackTool();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stellt sicher, dass mindestens das Status-Werkzeug angeboten wird - ohne
|
||||||
|
/// <see cref="Tools"/> anzufassen, wenn bereits etwas darin steht (egal ob Status- oder echte
|
||||||
|
/// Werkzeuge), um ein versehentliches Überschreiben bei überlappenden Aufrufen zu vermeiden.</summary>
|
||||||
|
public void EnsureFallbackTool()
|
||||||
|
{
|
||||||
|
if (Tools.Count > 0) return;
|
||||||
|
Tools.Add(new StatusMcpServerTool(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DisconnectAsync()
|
||||||
|
{
|
||||||
|
await _gate.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_client = null;
|
||||||
|
if (_pipe is not null)
|
||||||
|
{
|
||||||
|
await _pipe.DisposeAsync();
|
||||||
|
_pipe = null;
|
||||||
|
}
|
||||||
|
using (Tools.DeferChangedEvents())
|
||||||
|
{
|
||||||
|
Tools.Clear();
|
||||||
|
Tools.Add(new StatusMcpServerTool(this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { _gate.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Läuft für die gesamte Prozesslaufzeit mit, unabhängig davon, ob der KI-Client
|
||||||
|
/// jemals von sich aus "lehrerapp_status" aufruft - sonst würden die echten Werkzeuge erst
|
||||||
|
/// wieder auftauchen, nachdem jemand aktiv danach gefragt hat.</summary>
|
||||||
|
public async Task RunBackgroundReconnectLoopAsync(TimeSpan interval, CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try { await Task.Delay(interval, ct); }
|
||||||
|
catch (OperationCanceledException) { return; }
|
||||||
|
|
||||||
|
if (_client is null)
|
||||||
|
{
|
||||||
|
try { await TryReconnectAsync(ct); }
|
||||||
|
catch (OperationCanceledException) { return; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CallToolResult ErrorResult(string message) => new()
|
||||||
|
{
|
||||||
|
IsError = true,
|
||||||
|
Content = [new TextContentBlock { Text = message }],
|
||||||
|
};
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_pipe is not null) await _pipe.DisposeAsync();
|
||||||
|
_gate.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
namespace LehrerApp.McpBridge;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Einziges Werkzeug, das die Bridge anbietet, solange keine Verbindung zu LehrerApp besteht (siehe
|
||||||
|
/// <see cref="RealServerBridge"/>). Ohne dieses Werkzeug hätte ein KI-Client keine Möglichkeit zu
|
||||||
|
/// erfahren, WARUM gerade keine LehrerApp-Werkzeuge da sind, sondern sähe nur eine leere
|
||||||
|
/// Werkzeugliste - der Assistent könnte dem Nutzer dann nicht erklären, dass LehrerApp erst
|
||||||
|
/// gestartet werden muss. Versucht bei jedem Aufruf aktiv erneut zu verbinden; klappt das, ersetzt
|
||||||
|
/// <see cref="RealServerBridge"/> dieses Werkzeug automatisch durch die echte Werkzeugliste, und der
|
||||||
|
/// Client wird per "notifications/tools/list_changed" benachrichtigt (siehe
|
||||||
|
/// <see cref="McpServerPrimitiveCollection{T}"/>).
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class StatusMcpServerTool(RealServerBridge bridge) : McpServerTool
|
||||||
|
{
|
||||||
|
public override Tool ProtocolTool { get; } = new()
|
||||||
|
{
|
||||||
|
Name = "lehrerapp_status",
|
||||||
|
Description = "Prüft, ob LehrerApp erreichbar ist, und versucht bei Bedarf erneut zu " +
|
||||||
|
"verbinden. Solange keine Verbindung besteht, sind alle anderen LehrerApp-Werkzeuge " +
|
||||||
|
"(Schüler, Noten, Stundenplanung, Kompetenzen, ...) nicht verfügbar - sie erscheinen erst " +
|
||||||
|
"nach einer erfolgreichen Verbindung in der Werkzeugliste. Bei einer Fehlermeldung eines " +
|
||||||
|
"anderen LehrerApp-Werkzeugs, die auf eine fehlende Verbindung hindeutet, dieses Werkzeug " +
|
||||||
|
"aufrufen, um den Grund zu klären, statt den Nutzer ohne Erklärung zu vertrösten.",
|
||||||
|
InputSchema = JsonDocument.Parse("""{"type":"object","properties":{}}""").RootElement,
|
||||||
|
};
|
||||||
|
public override IReadOnlyList<object> Metadata { get; } = [];
|
||||||
|
|
||||||
|
public override async ValueTask<CallToolResult> InvokeAsync(
|
||||||
|
RequestContext<CallToolRequestParams> request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var connected = await bridge.TryReconnectAsync(cancellationToken);
|
||||||
|
return new CallToolResult
|
||||||
|
{
|
||||||
|
Content =
|
||||||
|
[
|
||||||
|
new TextContentBlock
|
||||||
|
{
|
||||||
|
Text = connected
|
||||||
|
? "Verbindung zu LehrerApp hergestellt. Die eigentlichen LehrerApp-Werkzeuge " +
|
||||||
|
"sind jetzt verfügbar."
|
||||||
|
: "LehrerApp läuft nicht oder der MCP-Server ist in den Einstellungen nicht " +
|
||||||
|
"aktiviert. Bitte den Nutzer bitten, LehrerApp zu starten und den " +
|
||||||
|
"MCP-Server in den Einstellungen zu aktivieren - dieses Werkzeug danach " +
|
||||||
|
"erneut aufrufen, um zu prüfen, ob die Verbindung jetzt klappt.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -181,6 +181,8 @@ public sealed class CompetencyCatalogImportServiceTests
|
|||||||
|
|
||||||
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||||
Current.Where(x => x.SubjectId == subjectId && x.GradeLevel == gradeLevel).ToList();
|
Current.Where(x => x.SubjectId == subjectId && x.GradeLevel == gradeLevel).ToList();
|
||||||
|
public List<CompetencyDomain> GetBySubject(Guid subjectId) =>
|
||||||
|
Current.Where(x => x.SubjectId == subjectId).ToList();
|
||||||
public CompetencyDomain? GetById(Guid id) => Current.FirstOrDefault(x => x.Id == id);
|
public CompetencyDomain? GetById(Guid id) => Current.FirstOrDefault(x => x.Id == id);
|
||||||
|
|
||||||
public void Save(CompetencyDomain domain) => Current.Add(domain);
|
public void Save(CompetencyDomain domain) => Current.Add(domain);
|
||||||
|
|||||||
@@ -2023,6 +2023,69 @@ WebUntis auffallen").
|
|||||||
Der Fehlzeitenabgleich-Button in der einzelnen Lerngruppe (`GroupDetailView`) blieb unverändert,
|
Der Fehlzeitenabgleich-Button in der einzelnen Lerngruppe (`GroupDetailView`) blieb unverändert,
|
||||||
da kontextgebunden.
|
da kontextgebunden.
|
||||||
|
|
||||||
|
**Nachtrag zum Untis-Hub (September 2026):** Nutzer-Feedback aus der ersten echten Nutzung — fast
|
||||||
|
alle Zeilen liefen anfangs noch nie, dabei fiel auf, dass ein soeben abgeschlossener Langzeit-
|
||||||
|
Fehlzeitenabgleich die kurzfristige Kadenz derselben Gruppe unverändert als fällig stehen ließ,
|
||||||
|
obwohl deren Zeitfenster (seit Schuljahresbeginn) das kurzfristige (letzte 14 Tage) als Teilmenge
|
||||||
|
vollständig enthält.
|
||||||
|
- `UntisHubService.RecordRun` schließt bei `FehlzeitenLang` jetzt automatisch auch
|
||||||
|
`FehlzeitenKurz` derselben Gruppe mit demselben Zeitstempel ab (eigener `LastResultSummary`-Text
|
||||||
|
"durch Abgleich seit Schuljahresbeginn mit erledigt"). Nicht symmetrisch: ein Kurz-Lauf deckt das
|
||||||
|
lange Fenster nicht ab und lässt `FehlzeitenLang` deshalb unangetastet.
|
||||||
|
- Im Fehlzeitenabgleich je Unterricht ([WebUntisLessonAbsenceComparisonViewModel.cs](LehrerApp.Desktop/ViewModels/Groups/WebUntisLessonAbsenceComparisonViewModel.cs))
|
||||||
|
war der aus dem WebUntis-Datensatz abgeleitete Zielstatus (`MapStatus`) bisher nur als Text
|
||||||
|
sichtbar und wurde 1:1 übernommen oder gar nicht — Nutzer-Feedback: der Status-String aus WebUntis
|
||||||
|
ließ oft nicht erkennen, wohin die automatische Übernahme führt, eine Zeile wurde deshalb öfter
|
||||||
|
abgelehnt und der Status danach manuell außerhalb des Dialogs nachgetragen. Spalte "Übernahme als"
|
||||||
|
ist jetzt eine ComboBox (`WebUntisLessonAbsenceRow.StatusOptions`/`SelectedStatusName`, gleiches
|
||||||
|
String-Wrapper-Muster wie `GradeCategoryDisplay`), vorbelegt mit dem berechneten Zielstatus, aber
|
||||||
|
vor dem Übernehmen frei auf einen von sechs plausiblen Status änderbar (die WebUntis hier nie
|
||||||
|
meldet — "Geschwänzt", "Suspendiert" usw. — bleiben bewusst außen vor).
|
||||||
|
- KI-gestützter Statusvorschlag über das bestehende ai-backend (bisher nur für die
|
||||||
|
Unterrichtsplanung genutzt) ergänzt: neuer Endpunkt `ai-backend/untis-status.php` (gleiches
|
||||||
|
Auth-/Abrechnungsmuster wie `explain.php`) und `AiPlanningService.RequestUntisStatusSuggestionsAsync`.
|
||||||
|
Bewusst ohne jeden Personenbezug im Payload (Nutzer-Vorgabe: "keimfrei", solange kein Name
|
||||||
|
mitgeschickt wird) — jede Zeile bekommt nur eine rein technische Positions-Id sowie die
|
||||||
|
strukturierten Rohsignale (Fehlminuten, "bereits bearbeitet"-Kennzeichen, ENr-Klammerung) und den
|
||||||
|
Freitext-Grund, nie Name/Klasse/Datum. Alle fraglichen Zeilen eines Abgleichslaufs gehen in
|
||||||
|
**einer** gebündelten Anfrage raus statt je Zeile einzeln (Kosten/Latenz-Überlegung aus der
|
||||||
|
Nutzerdiskussion — der Fix-Overhead pro Anfrage dominiert sonst die kurze Nutzlast). Gegen
|
||||||
|
Verwechslung (Nutzer-Sorge: "die KI könnte was vermischen") wird nicht auf Reihenfolge vertraut,
|
||||||
|
sondern hart auf Id-Mengengleichheit geprüft — weicht die zurückgegebene Id-Menge auch nur
|
||||||
|
minimal ab (fehlend/zusätzlich/doppelt), wird die gesamte Antwort verworfen, siehe die vier neuen
|
||||||
|
Tests in `AiPlanningServiceTests.cs`. Der Vorschlag setzt nur die "Übernahme als"-ComboBox vor
|
||||||
|
(neuer Button "KI-Vorschlag für Übernahme laden" im Fehlzeitenabgleich), übernimmt nie
|
||||||
|
eigenständig etwas — "Markierte übernehmen" bleibt der einzige schreibende Schritt.
|
||||||
|
- MCP-Zugriff von außen auf die Untis-Abgleiche umgesetzt, zweigleisig wie vom Nutzer entschieden —
|
||||||
|
neue Klasse [UntisComparisonTools.cs](LehrerApp.Desktop/Services/Mcp/Tools/UntisComparisonTools.cs):
|
||||||
|
- `get_untis_hub_status` (Read): reine Delegation an `UntisHubService.GetRows()`, kein eigener
|
||||||
|
WebUntis-Zugriff.
|
||||||
|
- `get_untis_absence_rows`/`apply_untis_absence_status` (bevorzugter, anonymer Weg): eine rein
|
||||||
|
technische `row-id` (In-Memory, pro Prozesslaufzeit) ordnet zurück, nie ein Schülername verlässt
|
||||||
|
die App. Nur Zeilen, die WebUntis über die externe Schülernummer (ENr) eindeutig einem
|
||||||
|
Kursmitglied zuordnet, werden überhaupt gelistet — die namensbasierte Fallback-Zuordnung des
|
||||||
|
interaktiven Dialogs bleibt dafür bewusst außen vor (eine autonome KI-Schreibaktion ohne Mensch,
|
||||||
|
der eine Namensauflösung visuell gegenprüft, wäre sonst riskanter als im Dialog). Das schreibende
|
||||||
|
Tool bestätigt wie jedes andere Write-Tool, die Bestätigungsmeldung nennt aber bewusst nur Datum/
|
||||||
|
Lerngruppe/Zielstatus, nie den Namen.
|
||||||
|
- `get_named_untis_absence_pattern` (bewusste, eng begrenzte Ausnahme von der sonst in
|
||||||
|
`McpToolScope.cs` beschriebenen Regel): verknüpft Name UND Fehlzeiten für explizit angegebene
|
||||||
|
Schüler-IDs, z.B. für einen Fehlmuster-Vergleich zwischen zwei Schülern — Datensparsamkeit über
|
||||||
|
den Aufrufparameter (nur die angefragten IDs, nicht der ganze Kurs) statt einer Auswahl-UI, um
|
||||||
|
keinen weiteren Dialogtyp einzuführen. Erfordert JEDES MAL eine gesonderte Bestätigung ohne
|
||||||
|
Sitzungsfreigabe (neuer Parameter `allowSessionTrust` auf `IMcpConfirmationService.ConfirmAsync`,
|
||||||
|
Standard weiterhin `true` für alle bestehenden Write-Tools) — `AvaloniaMcpConfirmationService`
|
||||||
|
prüft und setzt die Sitzungsfreigabe dafür gar nicht erst, `McpConfirmDialog` blendet die beiden
|
||||||
|
"nicht mehr nachfragen"-Kontrollkästchen für diesen Fall aus.
|
||||||
|
- `MapStatus`/die ENr-Zuordnung wurden aus `WebUntisLessonAbsenceComparisonViewModel` in das neue,
|
||||||
|
framework-freie `UntisLessonAbsenceHelper` (LehrerApp.Desktop/Services/) verschoben, damit Dialog
|
||||||
|
und MCP-Tool niemals unterschiedliche Statusvorschläge für dieselbe Fehlzeile berechnen.
|
||||||
|
- Bewusst ohne Tests für `GetUntisAbsenceRows`/`GetNamedUntisAbsencePattern` gelassen (gleiche
|
||||||
|
Begründung wie bei den drei bestehenden Abgleichs-ViewModels: brauchen einen echten WebUntis-
|
||||||
|
JSON-RPC-Zugriff) — `GetUntisHubStatus` und die Validierungspfade von `ApplyUntisAbsenceStatus`
|
||||||
|
(unbekannter/nicht zulässiger Status, unbekannte/abgelaufene row-id, jeweils ohne Bestätigung
|
||||||
|
erreicht) sind in `UntisComparisonToolsTests.cs` abgedeckt.
|
||||||
|
|
||||||
### 4.4 Wochen-/Tagesansicht
|
### 4.4 Wochen-/Tagesansicht
|
||||||
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
|
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
|
||||||
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.
|
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.
|
||||||
@@ -2650,6 +2713,168 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
|
|||||||
ohne erneutes `get_lesson_plans` möglich ist. 4 neue Tests in
|
ohne erneutes `get_lesson_plans` möglich ist. 4 neue Tests in
|
||||||
[McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs) (jetzt 32).
|
[McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs) (jetzt 32).
|
||||||
|
|
||||||
|
- [x] **4.5.31** Bestätigungsdialog-Prominenz + `move_lesson`/`delete_lesson` (2026-09-12,
|
||||||
|
Nutzer-Feedback nach erstem Live-Test mit echtem Bridge-Prozess).
|
||||||
|
- **Dialog fiel zu wenig auf:** der bisher wiederverwendete `ConfirmDialog` (Views/Shared,
|
||||||
|
an vielen anderen Stellen der App für Routine-Bestätigungen im Einsatz) ist nur zum
|
||||||
|
Hauptfenster modal — läuft LehrerApp im Hintergrund, während der Anstoß von einem
|
||||||
|
KI-Client in einem anderen Fenster kommt (der Normalfall bei MCP), geht der Dialog leicht
|
||||||
|
unter. Neuer, eigenständiger
|
||||||
|
[McpConfirmDialog](LehrerApp.Desktop/Views/Mcp/McpConfirmDialog.axaml) statt einer
|
||||||
|
Style-Änderung am geteilten `ConfirmDialog` (hätte alle dessen bestehenden Aufrufer
|
||||||
|
mitbetroffen): breiter (520 statt 420), auffälliger Kopfbereich ("Vorschlag deines
|
||||||
|
KI-Assistenten — bitte prüfen"), `Topmost`. `AvaloniaMcpConfirmationService` holt das
|
||||||
|
Hauptfenster zusätzlich aus einer möglichen Minimierung und aktiviert es
|
||||||
|
(`WindowState`/`Activate()`), bevor der Dialog erscheint.
|
||||||
|
- **`move_lesson`:** deckt sowohl "eine Stunde verschieben" als auch "Lücke für eine neue
|
||||||
|
Stunde öffnen" ab, indem es die bereits vorhandene, bislang nur UI-seitig genutzte
|
||||||
|
[LessonSchedulingService.Move](LehrerApp.Core/Services/LessonSchedulingService.cs) kapselt
|
||||||
|
(`shiftFollowingLessons=true` verschiebt alle späteren, noch nicht durchgeführten Stunden
|
||||||
|
derselben Einheit um denselben Tages-Versatz mit — "Lücke öffnen" = die Stunde hinter der
|
||||||
|
Lücke so verschieben, danach `create_lesson` auf das freigewordene Datum). Keine neue
|
||||||
|
Terminlogik geschrieben, nur wiederverwendet.
|
||||||
|
- **`delete_lesson` — bewusste, gezielte Ausnahme von "v1 ohne Lösch-Tools":** explizite
|
||||||
|
Nutzeranforderung. `Lesson` hängt (anders als die meisten anderen Entitäten) nicht am
|
||||||
|
Papierkorb-System — `ILessonRepository.Delete` löscht endgültig, kein `Restore`. Deshalb:
|
||||||
|
eigene `McpToolScope.AllowedDestructiveWriteTools`-Liste getrennt von den normalen
|
||||||
|
Write-Tools (fällt beim Lesen sofort auf), `Destructive = true`-Annotation im
|
||||||
|
MCP-Protokoll, und eine Bestätigungsnachricht, die explizit "NICHT rückgängig zu machen"
|
||||||
|
sagt (Wortlaut an den bestehenden Lösch-Dialog in `PlanningTabView.axaml.cs`
|
||||||
|
angelehnt: "wird endgültig gelöscht").
|
||||||
|
- 8 neue Tests in [McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs) (jetzt 40),
|
||||||
|
u.a.: `shiftFollowingLessons` verschiebt nur Stunden NACH der bewegten, nicht davor;
|
||||||
|
ein belegtes Ziel liefert nach bereits erfolgter Bestätigung einen Fehler statt
|
||||||
|
stillschweigend zu überschreiben (die bestehende `EnsureTargetIsFree`-Prüfung in
|
||||||
|
`LessonSchedulingService` greift unverändert).
|
||||||
|
|
||||||
|
- [x] **4.5.32** Sitzungsfreigabe für den Bestätigungsdialog + `LessonStatus.Cancelled`
|
||||||
|
(2026-09-12, Nutzer-Feedback: 17x "Phase hinzufügen" einzeln bestätigen müssen ist
|
||||||
|
unzumutbar).
|
||||||
|
- **Sitzungsfreigabe:** `McpConfirmDialog` bietet jetzt zwei Checkboxen ("diese Art von
|
||||||
|
Aktion" / "alle KI-Aktionen für den Rest der Sitzung nicht mehr nachfragen"), nur beim
|
||||||
|
Bestätigen wirksam (Ablehnen + Checkbox wäre widersprüchlich, wird ignoriert — siehe
|
||||||
|
`McpConfirmDialog.axaml.cs`). `IMcpConfirmationService.ConfirmAsync` bekommt einen neuen
|
||||||
|
`operationKey`-Parameter, bewusst per `[CallerMemberName]` automatisch befüllt (Name der
|
||||||
|
aufrufenden Tool-Methode, z.B. "AddLessonPhase") — kein bestehender Aufruf in den
|
||||||
|
Tool-Klassen musste dafür geändert werden. Freigaben leben als reines In-Memory-Bookkeeping
|
||||||
|
auf der `AvaloniaMcpConfirmationService`-Singleton-Instanz, gelten bis zum Beenden der App
|
||||||
|
(neuer Prozess = neue Instanz = wieder alles ungetraut), nie persistiert. Automatisch
|
||||||
|
bestätigte Aufrufe werden trotzdem geloggt (`AppLogger.Info`), damit stillschweigende
|
||||||
|
Bestätigungen nicht spurlos bleiben.
|
||||||
|
- **`LessonStatus.Cancelled`** ("Ausgefallen") als Alternative zu `delete_lesson`, wenn eine
|
||||||
|
Stunde nur ausgefallen ist (Exkursion, Feiertag, Vertretung), aber als Ereignis
|
||||||
|
dokumentiert bleiben soll — als `Cancelled = 4` angehängt (nicht eingefügt, siehe
|
||||||
|
Kommentar auf dem Enum: bestehende LiteDB-Werte 0/1 sind fix). `delete_lesson`s
|
||||||
|
Tool-Beschreibung weist jetzt aktiv auf diese Alternative hin.
|
||||||
|
`LessonStatusDisplay.Options`/`ToName`/`FromName` erweitert — der bestehende
|
||||||
|
Status-Auswahl-`ComboBox` in `LessonDialog.axaml` (bindet bereits an `StatusOptions`)
|
||||||
|
zeigt "Ausgefallen" dadurch automatisch an, ohne XAML-Änderung.
|
||||||
|
- Konsistente Behandlung wie bereits durchgeführte Stunden an allen Stellen, die bisher
|
||||||
|
nur auf `Conducted` prüften (eine ausgefallene Stunde ist ebenfalls "resolved", nicht
|
||||||
|
mehr offen): `LessonSchedulingService.Move`/`SplitAndMoveSecondPart` (nicht mitverschoben
|
||||||
|
bzw. Fortsetzung auf Entwurf zurückgesetzt), `GroupOverviewViewModel.LoadNextLesson`
|
||||||
|
(nicht als nächste Stunde vorgeschlagen), `AdvanceLessonStatus` (Endzustand, kein
|
||||||
|
Weiterschalten). Fortschrittsanzeige (`UnitSummary`) zählt ausgefallene Stunden weder
|
||||||
|
zum Pensum noch zu den gehaltenen Stunden (Nenner sinkt statt künstlich zu wachsen).
|
||||||
|
- `LoadOpenHomeworkCheck`/`HasUnhandledHomework` (Hausaufgabe-kontrollieren-Erinnerung)
|
||||||
|
überspringen ausgefallene Stunden bei der Suche nach der "letzten Stunde" — sonst hätte
|
||||||
|
eine ausgefallene Stunde mit liegengebliebenem Hausaufgabentext fälschlich weiter genagt.
|
||||||
|
- 9 neue Tests: 2 in
|
||||||
|
[LessonSchedulingServiceTests.cs](LehrerApp.Desktop.Tests/LessonSchedulingServiceTests.cs),
|
||||||
|
neues [LessonStatusDisplayTests.cs](LehrerApp.Desktop.Tests/LessonStatusDisplayTests.cs)
|
||||||
|
(Round-Trip aller Status-Namen + Fortschrittsberechnung), 1 in
|
||||||
|
[McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs) (belegt den
|
||||||
|
`[CallerMemberName]`-Mechanismus ohne echtes UI).
|
||||||
|
|
||||||
|
- [x] **4.5.33** Fach-/Kompetenzkatalog-Verwaltung + Kompetenzzuordnung zu Einzelstunden über MCP
|
||||||
|
(2026-09-12, Nutzer-Nachtrag): Nutzer möchte einem KI-Agenten die volle Verwaltung der
|
||||||
|
Fächer (`Subject`) und des Kompetenzkatalogs (`CompetencyDomain`/`CompetencyItem`, siehe
|
||||||
|
Kompetenzen-Tab in den Einstellungen, Kapitel 8) überlassen — anlegen, ändern, löschen, "einfach
|
||||||
|
alles was dazugehört" — sowie das Zuordnen von Kompetenzcodes zu einzelnen Stunden.
|
||||||
|
- Neue [CompetencyTools.cs](LehrerApp.Desktop/Services/Mcp/Tools/CompetencyTools.cs):
|
||||||
|
`get_subjects`/`get_competency_catalog` (Read), `create_subject`/`update_subject`/
|
||||||
|
`create_competency_domain`/`update_competency_domain`/`add_competency_item`/
|
||||||
|
`update_competency_item`/`remove_competency_item` (Write, je mit Bestätigung), sowie
|
||||||
|
`delete_subject`/`delete_competency_domain` als **weitere, gezielte Ausnahmen** von der
|
||||||
|
"v1 ohne Lösch-Tools"-Regel neben `delete_lesson` (4.5.31) — beide Repositories löschen
|
||||||
|
endgültig ohne Papierkorb, deshalb `Destructive = true` und Aufnahme in
|
||||||
|
`McpToolScope.AllowedDestructiveWriteTools` statt der normalen Write-Liste.
|
||||||
|
`delete_subject` nutzt die bereits bestehende Verwendungsprüfung in
|
||||||
|
`SubjectRepository.Delete` (schlägt fehl, wenn noch eine Lerngruppe oder ein
|
||||||
|
Kompetenzkatalog das Fach referenziert) — keine neue Validierung nötig.
|
||||||
|
- `ICompetencyDomainRepository` um `GetBySubject(Guid)` erweitert (alle Klassenstufen eines
|
||||||
|
Fachs), da bisher jede Katalog-Abfrage im gesamten Code eine bekannte `gradeLevel` verlangte
|
||||||
|
(`GetBySubjectAndGrade`) — für `get_competency_catalog` ohne Klassenstufen-Filter fehlte
|
||||||
|
diese Möglichkeit. `CompetencyDomainRepository`/`FakeCompetencyDomains` entsprechend ergänzt.
|
||||||
|
- **Kompetenzzuordnung zu Lessons:** `Lesson` bekommt ein neues Feld `Competencies`
|
||||||
|
(`List<string>`, analog zu `Unit.Competencies`) sowie zwei neue Tools in
|
||||||
|
[LessonPlanTools.cs](LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs):
|
||||||
|
`add_lesson_competency`/`remove_lesson_competency`. Bewusst granular statt eines
|
||||||
|
"set_lesson_competencies", das die ganze Liste ersetzt — dieselbe Race-Condition-Überlegung
|
||||||
|
wie bei den Verlaufsplan-Phasen (4.5.28). Bewusst auf **Stundenebene**, nicht je Phase — die
|
||||||
|
in TODO.md 4.5.8 beschriebene feinere Zuordnung je Verlaufsplan-Phase bleibt offen und ist
|
||||||
|
von dieser Änderung unabhängig; `Lesson.Competencies` ist ein eigenständiges, gröberes Feld.
|
||||||
|
- `FakeSubjects` (Tests) von No-Op-Stub auf echtes In-Memory-CRUD (inkl. Namens-Dubletten-Prüfung
|
||||||
|
analog zu `SubjectRepository.Save`) umgestellt, da bisher kein Test tatsächlich über MCP
|
||||||
|
schrieb und ein reiner No-Op genügte.
|
||||||
|
- 16 neue Tests in [McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs) (jetzt 57).
|
||||||
|
|
||||||
|
- [x] **4.5.34** `LehrerApp.McpBridge` zu einem resilienten MCP-Proxy umgebaut, statt bei
|
||||||
|
fehlendem Kontakt zu LehrerApp den ganzen Prozess fehlschlagen zu lassen (2026-09-12,
|
||||||
|
Nutzer-Feedback): Claude Desktop startet per Windows-Autostart oft, bevor LehrerApp.Desktop
|
||||||
|
läuft. Die Bridge führte den MCP-Initialize-Handshake bisher selbst nur einmal beim
|
||||||
|
Prozessstart durch und ließ ihn fehlschlagen, wenn die Pipe zu diesem Zeitpunkt nicht
|
||||||
|
erreichbar war — Claude Desktop markiert einen MCP-Server nach einem fehlgeschlagenen
|
||||||
|
Handshake als tot und deaktiviert ihn für die Sitzung, ohne erneut zu versuchen. Ein erster
|
||||||
|
Zwischenschritt (nur die Fehlermeldung nach Ursache unterscheiden, `TimeoutException` vs.
|
||||||
|
echter Fehler) hätte daran nichts geändert — auf Nachfrage entschieden, stattdessen das
|
||||||
|
eigentliche Problem zu lösen.
|
||||||
|
- **Neue Architektur:** die Bridge ist jetzt selbst ein vollwertiger MCP-Server (über
|
||||||
|
`ModelContextProtocol.Server`, dieselbe SDK-Bausteine wie `McpServerHostedService` in
|
||||||
|
LehrerApp.Desktop) UND gleichzeitig ein MCP-**Client** gegenüber dem echten Server in
|
||||||
|
LehrerApp.Desktop (`ModelContextProtocol.Client.McpClient` über
|
||||||
|
`ModelContextProtocol.Protocol.StreamClientTransport` auf derselben Named Pipe). Der
|
||||||
|
Initialize-Handshake gegenüber Claude Desktop gelingt dadurch **immer**, unabhängig davon,
|
||||||
|
ob LehrerApp erreichbar ist.
|
||||||
|
- Neues [RealServerBridge.cs](LehrerApp.McpBridge/RealServerBridge.cs): hält die Client-
|
||||||
|
Verbindung zu LehrerApp und spiegelt deren aktuelle Werkzeugliste 1:1 (Name, Beschreibung,
|
||||||
|
Eingabeschema unverändert übernommen) in die Werkzeugsammlung, die die Bridge Claude
|
||||||
|
Desktop anbietet — kein eigenes Wissen über einzelne LehrerApp-Werkzeuge nötig, ändert sich
|
||||||
|
eins in LehrerApp.Desktop, übernimmt die Bridge das automatisch beim nächsten
|
||||||
|
Verbindungsaufbau. Ist LehrerApp nicht erreichbar, bietet die Bridge statt der echten
|
||||||
|
Werkzeuge genau ein Werkzeug an,
|
||||||
|
[StatusMcpServerTool.cs](LehrerApp.McpBridge/StatusMcpServerTool.cs)
|
||||||
|
(`lehrerapp_status`) — es erklärt der KI, dass LehrerApp gestartet werden muss, und
|
||||||
|
versucht bei jedem Aufruf aktiv erneut zu verbinden. Ein Hintergrund-Loop
|
||||||
|
(`RunBackgroundReconnectLoopAsync`, alle 5s) versucht das unabhängig davon ebenfalls
|
||||||
|
laufend, damit die echten Werkzeuge auch ohne aktives Nachfragen zurückkommen, sobald der
|
||||||
|
Nutzer LehrerApp startet. Jeder Wechsel zwischen Status-Werkzeug und echter Werkzeugliste
|
||||||
|
löst automatisch `notifications/tools/list_changed` aus
|
||||||
|
(`McpServerPrimitiveCollection<T>.DeferChangedEvents`), worauf ein MCP-konformer Client
|
||||||
|
seine Werkzeugliste neu abruft.
|
||||||
|
- [ProxyMcpServerTool.cs](LehrerApp.McpBridge/ProxyMcpServerTool.cs) reicht einen Tool-Aufruf
|
||||||
|
unverändert an den echten Client weiter; bricht die Verbindung während eines Aufrufs ab
|
||||||
|
(LehrerApp wurde beendet), liefert `RealServerBridge.CallRealToolAsync` ein reguläres
|
||||||
|
`CallToolResult` mit `IsError = true` und einer erklärenden Nachricht zurück — kein
|
||||||
|
Transport-/Prozessabbruch, die Sitzung mit Claude Desktop bleibt bestehen, nur dieser eine
|
||||||
|
Aufruf schlägt sichtbar (für den Assistenten erklärbar) fehl.
|
||||||
|
- [ConnectionDiagnostics.cs](LehrerApp.McpBridge/ConnectionDiagnostics.cs): dieselbe
|
||||||
|
Unterscheidung wie im verworfenen Zwischenschritt (`TimeoutException` = LehrerApp läuft
|
||||||
|
nicht/MCP-Server nicht aktiviert, alles andere = echtes Konfigurations-/Rechteproblem, z.B.
|
||||||
|
ein ACL-Mismatch zwischen den Prozessen) — jetzt als gemeinsam genutzter Baustein an allen
|
||||||
|
drei Verbindungsversuchsstellen (initialer Connect, Hintergrund-Loop, Status-Werkzeug)
|
||||||
|
statt nur im alten Einzel-Connect-Pfad.
|
||||||
|
- End-to-End mit einer echten, laufenden LehrerApp.Desktop-Instanz verifiziert (manuelles
|
||||||
|
JSON-RPC über die Bridge): erfolgreicher Connect übernimmt alle 35 registrierten Werkzeuge
|
||||||
|
mit vollem Schema; `get_students` liefert echte Daten; LehrerApp.Desktop während einer
|
||||||
|
laufenden Sitzung beendet lässt einen in Arbeit befindlichen Aufruf mit lesbarer
|
||||||
|
Fehlermeldung statt Absturz fehlschlagen und schaltet auf das Status-Werkzeug um;
|
||||||
|
LehrerApp.Desktop neu gestartet stellt die volle Werkzeugliste innerhalb des
|
||||||
|
Hintergrund-Loop-Intervalls automatisch wieder her.
|
||||||
|
- Kein zusätzliches Feature wie Windows-Autostart für LehrerApp.Desktop selbst oder ein
|
||||||
|
Tray-Icon (auf Nachfrage bewusst nicht Teil dieser Änderung) — die Bridge löst das Problem
|
||||||
|
rein auf Protokollebene, unabhängig davon, wann der Nutzer LehrerApp tatsächlich startet.
|
||||||
|
|
||||||
**Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich,
|
**Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich,
|
||||||
dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem
|
dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem
|
||||||
bereits bestehenden Anwesenheits-Tracking aus Kapitel 3 (`ParticipationEntry.Attendance`,
|
bereits bestehenden Anwesenheits-Tracking aus Kapitel 3 (`ParticipationEntry.Attendance`,
|
||||||
|
|||||||
@@ -188,6 +188,14 @@ deaktiviert (`AllowOverride None`), oder es läuft nginx statt Apache (dort gilt
|
|||||||
grundsätzlich nicht) — dann hilft nur eine serverseitige Konfiguration durch den Hoster/Support
|
grundsätzlich nicht) — dann hilft nur eine serverseitige Konfiguration durch den Hoster/Support
|
||||||
(z.B. bei nginx ein `fastcgi_param HTTP_AUTHORIZATION $http_authorization;`).
|
(z.B. bei nginx ein `fastcgi_param HTTP_AUTHORIZATION $http_authorization;`).
|
||||||
|
|
||||||
|
## Update für bereits deployte Installationen (Fehlzeiten-Statusvorschlag, `untis-status.php`)
|
||||||
|
|
||||||
|
Kein neues DB-Schema nötig (nutzt dieselben `users`/`tokens`/`transactions`-Tabellen und dieselbe
|
||||||
|
`ai_backend_call_and_charge`-Abrechnung wie `plan.php`/`explain.php`/`gbu.php`). Einfach die neue
|
||||||
|
Datei `untis-status.php` hochladen. Bewusst ohne jeden Personenbezug im Payload (Nutzer-Vorgabe zum
|
||||||
|
Untis-Hub) — die Zeilen enthalten nur eine rein technische Positions-Id sowie Fehlminuten/
|
||||||
|
Bearbeitet-Kennzeichen/ENr-Klammerung und den Freitext-Grund, nie Name, Klasse oder Datum.
|
||||||
|
|
||||||
## Fehlerbehebung: KI-Antwort endet mitten im JSON
|
## Fehlerbehebung: KI-Antwort endet mitten im JSON
|
||||||
|
|
||||||
Eine im Rettungsdialog am Ende abgeschnittene Modellantwort entsteht typischerweise nicht durch
|
Eine im Rettungsdialog am Ende abgeschnittene Modellantwort entsteht typischerweise nicht durch
|
||||||
@@ -211,6 +219,12 @@ werden muss.
|
|||||||
- Ob Prompt Caching tatsächlich greift (`cache_read_input_tokens` > 0 bei einer zweiten Anfrage
|
- Ob Prompt Caching tatsächlich greift (`cache_read_input_tokens` > 0 bei einer zweiten Anfrage
|
||||||
innerhalb von 5 Minuten) — der `FakeProvider` simuliert kein Caching, das lässt sich nur gegen
|
innerhalb von 5 Minuten) — der `FakeProvider` simuliert kein Caching, das lässt sich nur gegen
|
||||||
die echte Anthropic-API beobachten (z.B. per Blick in die `transactions`-Tabelle).
|
die echte Anthropic-API beobachten (z.B. per Blick in die `transactions`-Tabelle).
|
||||||
|
- **`untis-status.php` (Fehlzeiten-Statusvorschlag):** dieselbe Einschränkung wie oben (echtes JSON
|
||||||
|
im erwarteten Schema, korrekte Kostenberechnung) gilt hier ebenso, zusätzlich ungeprüft: ob das
|
||||||
|
Modell die vorgegebene, feste Statusliste zuverlässig einhält statt eigene Werte zu erfinden
|
||||||
|
(der Desktop-Client verwirft einen nicht in dieser Liste enthaltenen Wert defensiv, siehe
|
||||||
|
`WebUntisLessonAbsenceRow.SelectableStatuses`), sowie ob die Id-Rückgabetreue in der Praxis so
|
||||||
|
zuverlässig ist wie in den Tests angenommen (dort nur mit statischen Antworten simuliert).
|
||||||
- **`substance.php`/`stoffliste.php` (Chemikalien-Recherche):** braucht keinen echten API-Key und
|
- **`substance.php`/`stoffliste.php` (Chemikalien-Recherche):** braucht keinen echten API-Key und
|
||||||
keine Netzwerkverbindung mehr, ist also grundsätzlich einfacher zu verifizieren als der Rest
|
keine Netzwerkverbindung mehr, ist also grundsätzlich einfacher zu verifizieren als der Rest
|
||||||
dieser Liste — trotzdem NICHT in einer echten PHP-Umgebung gelaufen (kein PHP auf der
|
dieser Liste — trotzdem NICHT in einer echten PHP-Umgebung gelaufen (kein PHP auf der
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?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);
|
||||||
|
|
||||||
|
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['rows']) || !is_array($body['rows']) || count($body['rows']) === 0) {
|
||||||
|
ai_backend_fail(400, 'Ungültige Anfrage.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datensparsamkeit (Nutzer-Feedback zum Untis-Hub): dieser Endpunkt bekommt bewusst NIE Namen,
|
||||||
|
// Klassen oder Datumsangaben - nur eine je Zeile rein technische, für die KI bedeutungslose "id"
|
||||||
|
// (für die Rückzuordnung im Desktop-Client) sowie die für die Statuseinschätzung tatsächlich
|
||||||
|
// relevanten Felder aus dem WebUntis-Fehlzeitendatensatz. Mehrere Zeilen eines Abgleichslaufs
|
||||||
|
// werden in einer Anfrage gebündelt statt je Zeile eine eigene Anfrage zu stellen (Kosten/Latenz).
|
||||||
|
$systemPrompt = <<<PROMPT
|
||||||
|
Du hilfst einer Lehrkraft beim Abgleich von WebUntis-Fehlzeiten mit dem internen Anwesenheitsstatus
|
||||||
|
ihrer Schulverwaltungs-App. Du bekommst eine Liste anonymisierter Fehlzeiten-Zeilen (KEINE Namen,
|
||||||
|
KEINE Klassen, KEINE Daten) und sollst je Zeile den passendsten internen Status vorschlagen.
|
||||||
|
|
||||||
|
## Eingabeschema
|
||||||
|
|
||||||
|
{
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"id": "<rein technische Kennung ohne inhaltliche Bedeutung, exakt so zurückzugeben>",
|
||||||
|
"reasonText": "<Freitext-Grund aus WebUntis, kann leer sein>",
|
||||||
|
"absentMinutes": <Zahl, gemeldete Fehlminuten der Unterrichtsstunde>,
|
||||||
|
"handledOn": <true, wenn der Eintrag laut WebUntis bereits vom Klassenlehrer bearbeitet wurde>,
|
||||||
|
"externKeyInParentheses": <true/false/null - schulinterne Konvention: Klammerung der
|
||||||
|
Entschuldigungsnummer bedeutet unentschuldigt, ohne Klammern entschuldigt; null, wenn keine
|
||||||
|
Nummer hinterlegt ist>,
|
||||||
|
"currentGuess": "<bereits regelbasiert ermittelter Status, siehe Schlüsselliste unten>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
## Möglicher Zielstatus (exakt einer dieser Schlüssel je Zeile)
|
||||||
|
|
||||||
|
- "Present": eigentlich anwesend (z.B. wenn der Freitext klar nahelegt, dass die Fehlzeit ein Irrtum war)
|
||||||
|
- "Late": Verspätung, kein vollständiges Fehlen der Stunde
|
||||||
|
- "LeftDuringClass": während des Unterrichts nach Hause entlassen/gegangen
|
||||||
|
- "ExcusePending": krank gemeldet, Entschuldigung noch offen/unbearbeitet
|
||||||
|
- "Excused": krank, entschuldigt
|
||||||
|
- "Unexcused": unentschuldigt
|
||||||
|
|
||||||
|
## Aufgabe
|
||||||
|
|
||||||
|
"currentGuess" kommt aus einer festen Regel über die strukturierten Felder und ist im Zweifel schon
|
||||||
|
korrekt - weiche davon nur ab, wenn "reasonText" eindeutig eine andere Einordnung nahelegt (z.B. ein
|
||||||
|
Text, der klar auf eine Klassenfahrt, einen Arzttermin mit bereits vorliegendem Attest, oder einen
|
||||||
|
erkennbaren Fehleintrag hindeutet). Bei Unsicherheit "currentGuess" unverändert übernehmen, nicht raten.
|
||||||
|
|
||||||
|
## Antwortformat
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Freitext davor/danach), mit GENAU einem Eintrag je
|
||||||
|
Eingabe-"id" (dieselbe Anzahl, dieselben "id"-Werte unverändert, keine erfundenen oder ausgelassenen
|
||||||
|
ids):
|
||||||
|
{ "suggestions": [ { "id": "<unverändert aus der Eingabe>", "status": "<einer der Schlüssel oben>" } ] }
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$userContent = json_encode($body);
|
||||||
|
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent);
|
||||||
|
|
||||||
|
$parsed = ai_backend_decode_json_response($result['content']);
|
||||||
|
if (!is_array($parsed) || !isset($parsed['suggestions']) || !is_array($parsed['suggestions'])) {
|
||||||
|
ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.');
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode($parsed);
|
||||||
Reference in New Issue
Block a user