Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Schuly.API/Helply/HelplyPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ How you work

Finding tests, dates, learning objectives and prep material
- Schools, and even teachers at the same school, publish this in different places. Never assume where something is: find out, and check every source you have before saying it doesn't exist.
- First pin down the assessment. Use list_exams with a date range, but an empty result does not mean there is no test: teachers often enter tests late or not at all. Then look for a class schedule document (Semesterplan, Jahresplan, Masterplan, Terminplan, Prüfungsplan, Beurteilungstermine) with search_files and read it with read_file; it is often the most reliable source for dates. Use list_grades for the subject to see past tests, their topics and their order. Note the subject and the teacher's name.
- First pin down the assessment. Use list_assessments with a date range (it merges list_exams with agenda entries that announce a test, presentation or hand-in; fall back to list_exams and list_agenda if it is unavailable), but an empty result does not mean there is no test: teachers often enter tests late or not at all. Then look for a class schedule document (Semesterplan, Jahresplan, Masterplan, Terminplan, Prüfungsplan, Beurteilungstermine) with search_files and read it with read_file; it is often the most reliable source for dates. Use list_grades for the subject to see past tests, their topics and their order. Note the subject and the teacher's name.
- Questions about upcoming tests or dates ("when is my next math test", "what tests do I have this month", "do I have anything graded next week") use the same discovery steps. Never answer "you have no tests" just because list_exams is empty: also check the class schedule documents (Semesterplan, Masterplan, Prüfungsplan, Beurteilungstermine ...), assessment-date pages in OneNote, team channel posts and files, and the teacher's mails before concluding, and say which sources you checked. If dates conflict between sources, show both with their source and date.
- Do not ask clarifying questions first. If the user names only a subject ("Lernziele Physik", "what's on the next math test", "Stoff für Geschichte"), assume the next upcoming assessment in that subject (or the most recent one if none is upcoming), run the discovery and state which test you assumed.
- Resolve the subject first, from the user's own data, before searching. Collect subject names from list_grades, list_agenda and list_exams, team and channel names from list_teams, and notebook and section names from list_notebooks and list_sections. They often name the same subject differently (Mathe / Mathematik / MA, W&R / Wirtschaft und Recht / WR, Geschichte und Politik / GP, Deutsch / DE, Physik / physique / fisica, and the user's own shorthand): match the user's word against all of them, in every language.
Expand Down
1 change: 1 addition & 0 deletions src/Schuly.API/Helply/HelplyToolLabels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public static class HelplyToolLabels
["list_semester_reports"] = ("Looking up your semester reports", "Looked up your semester reports"),
["list_exams"] = ("Checking your exams", "Checked your exams"),
["list_agenda"] = ("Checking your agenda", "Checked your agenda"),
["list_assessments"] = ("Looking up your tests and deadlines", "Looked up your tests and deadlines"),
["list_absences"] = ("Checking your absences", "Checked your absences"),
["list_documents"] = ("Looking through your documents", "Looked through your documents"),
["read_document"] = ("Reading a document", "Read a document"),
Expand Down
47 changes: 47 additions & 0 deletions src/Schuly.API/Mcp/Tools/AssessmentKinds.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace Schuly.API.Mcp.Tools
{
/// <summary>Recognises tests, presentations and hand-ins in the free text of an agenda entry, in German, French, Italian and English.</summary>
public static class AssessmentKinds
{
public const string Exam = "exam";
public const string Presentation = "presentation";
public const string HandIn = "hand-in";
public const string Other = "other";

// Words a token may merely contain: long and specific enough that German compounds (Mathetest, Projektabgabe, Schlusspruefung) are caught too.
private static readonly string[] PresentationParts = ["prasentation", "praesentation", "presentation", "presentazione", "vortrag", "referat", "expose", "exposition"];
private static readonly string[] ExamParts = ["prufung", "pruefung", "lernkontrolle", "klausur", "epreuve", "controle", "interrogation", "mundlich", "muendlich", "verific"];
private static readonly string[] HandInParts = ["abgabe", "abgeben", "einreichen", "projekt", "project", "projet", "progetto", "hausarbeit", "deadline", "submission", "consegna", "portfolio"];

// Short words only count as whole tokens, so "test" is found but "latest" and "contest" are not.
private static readonly string[] ExamWords = ["test", "tests", "lk", "lks", "exam", "exams", "examen", "examens", "esame", "esami", "quiz", "oral", "orale", "orali"];
private static readonly string[] HandInWords = ["rendu", "hand", "handin"];
private static readonly string[] NotATest = ["protest", "kontest", "contest", "attest", "latest", "detest"];
private static readonly string[] Negations = ["kein", "keine", "keiner", "keinen", "ohne", "no", "not", "pas", "sans", "nessun", "nessuna", "senza"];

/// <summary>
/// The kind of assessment the text announces (exam, presentation, hand-in), or null when it announces none. A presentation wins over an exam, an exam over a hand-in
/// ("Projektprüfung" is an exam, "Abgabe Projekt" a hand-in). A word right after "kein", "ohne", "no" and the like does not count.
/// </summary>
public static string? Classify(string? title, string? description)
{
var tokens = SubjectAliases.Normalize(title + " " + (description is { Length: > 300 } ? description[..300] : description)).Split(' ', StringSplitOptions.RemoveEmptyEntries);
var presentation = false;
var exam = false;
var handIn = false;
for (var i = 0; i < tokens.Length; i++)
{
if (i > 0 && Negations.Contains(tokens[i - 1]))
continue;
var t = tokens[i];
if (PresentationParts.Any(t.Contains))
presentation = true;
else if (ExamParts.Any(t.Contains) || ExamWords.Contains(t) || (t.Length >= 7 && t.EndsWith("test", StringComparison.Ordinal) && !NotATest.Contains(t)))
exam = true;
else if (HandInParts.Any(t.Contains) || HandInWords.Contains(t) && (t != "hand" || (i + 1 < tokens.Length && tokens[i + 1] == "in")))
handIn = true;
}
return presentation ? Presentation : exam ? Exam : handIn ? HandIn : null;
}
}
}
131 changes: 131 additions & 0 deletions src/Schuly.API/Mcp/Tools/SchulyAssessmentTools.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using System.ComponentModel;
using ModelContextProtocol;
using ModelContextProtocol.Server;
using Schuly.Application.Dtos;
using Schuly.Application.Queries.Agenda;
using Schuly.Application.Queries.Exam;
using Schuly.Application.Queries.SchoolUser;
using Schuly.Domain.Enums;
using Schuly.Domain.Mcp;
using static Schuly.API.Mcp.Tools.McpToolSupport;

namespace Schuly.API.Mcp.Tools
{
/// <summary>Assessments in one list: the exams the school entered, plus the agenda entries that announce a test, presentation or hand-in, without the same test twice.</summary>
public sealed partial class SchulyTools
{
private const int DefaultAssessmentDays = 30;

[McpServerTool(Name = "list_assessments", Title = "List assessments", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)]
[Description("Lists the user's assessments (tests, exams, presentations, hand-ins, oral exams) in a date range in ONE list, newest dates last: the exams from list_exams merged with agenda entries that look like assessments (words such as Test, Prüfung, LK, Lernkontrolle, Vortrag, Präsentation, Abgabe, Projekt, mündlich, examen, épreuve, contrôle, verifica, esame, presentation, hand-in in German, French, Italian and English). Each item has date, time if known, subject, title, kind (exam, presentation, hand-in, other), source (exams or agenda) and the teacher if known. The same test in exams and agenda is listed once. Default range: today to 30 days ahead. subject accepts any name of the subject in any language (Mathe, Mathematik, maths). It does NOT read schedule documents such as Masterplan, Semesterplan or Prüfungsplan, and teachers often enter tests late, so an empty list does not mean there is none: for those documents use search and read_file.")]
[McpAccess(McpArea.Exams, McpAccessLevel.Read)]
public async Task<string> ListAssessments([Description("First day, yyyy-MM-dd. Default today.")] DateOnly? from = null, [Description("Last day, yyyy-MM-dd. Default 30 days after from.")] DateOnly? to = null, [Description("Only this subject, by any of its names or abbreviations.")] string? subject = null, [Description("Items to skip, for paging.")] int offset = 0, [Description("Items per page, 1-200.")] int limit = 100, CancellationToken ct = default)
{
var start = from ?? SwissTime.Today();
var end = to ?? start.AddDays(DefaultAssessmentDays);
if (end < start)
throw new McpException("to must not be before from.");

var permissions = Caller(accessor).Permissions;
var schoolUsers = Unwrap(await mediator.Send(new GetSchoolUsersQuery(), ct));
var classNames = schoolUsers.SelectMany(s => s.Classes).GroupBy(c => c.ClassId).ToDictionary(g => g.Key, g => g.First().ClassName);
var exams = Unwrap(await mediator.Send(new GetExamsQuery(), ct));
var searched = new List<string> { "exams" };
var skipped = new List<object>();
List<AgendaEntryDto> agenda = [];
if (permissions.Allows(McpArea.Calendar, McpAccessLevel.Read))
{
searched.Add("agenda");
agenda = Unwrap(await mediator.Send(new GetAgendasQuery(), ct));
}
else
skipped.Add(new { source = "agenda", reason = "needs Calendar read" });

var items = Assessments(exams, agenda, classNames, start, end, subject);
return Serialize(new
{
from = start,
to = end,
subject = string.IsNullOrWhiteSpace(subject) ? null : subject.Trim(),
searched,
skipped = skipped.Count == 0 ? null : skipped,
note = "Only exams and agenda entries are read, not schedule documents (Masterplan, Semesterplan, Prüfungsplan). If something is missing, search for those with search and read them with read_file.",
assessments = Page(items.Select(i => new { date = i.Date, time = i.Time?.ToString("HH:mm"), subject = i.Subject, title = i.Title, kind = i.Kind, source = i.Source, teachers = i.Teachers.Count == 0 ? null : i.Teachers, alsoInAgenda = i.AlsoInAgenda ? true : (bool?)null, examId = i.ExamId, agendaEntryId = i.AgendaEntryId }), offset, limit, "assessments"),
});
}

/// <summary>The merged, de-duplicated assessments in the range, ordered by date and time. Public so the rules can be tested without a server.</summary>
public static List<AssessmentItem> Assessments(IReadOnlyList<ExamDto> exams, IReadOnlyList<AgendaEntryDto> agenda, IReadOnlyDictionary<Guid, string> classNames, DateOnly from, DateOnly to, string? subject)
{
string? ClassName(Guid? classId) => classId is { } id ? classNames.GetValueOrDefault(id) : null;
string Key(string? name) => string.IsNullOrWhiteSpace(name) ? string.Empty : SubjectAliases.Resolve(name)?.Key ?? SubjectAliases.Clean(name);

var lessons = agenda.Where(a => a.EntryType == AgendaEntryType.Lesson && AssessmentKinds.Classify(null, a.Description) is null).ToList();
var teachersByClass = lessons.Where(a => a.ClassId is not null).GroupBy(a => a.ClassId!.Value).ToDictionary(g => g.Key, g => SubjectTeachers.FromLessons(g.Select(a => a.Description)));
var teachersByKey = lessons.GroupBy(a => Key(ClassName(a.ClassId) ?? a.Title)).Where(g => g.Key.Length > 0).ToDictionary(g => g.Key, g => SubjectTeachers.FromLessons(g.Select(a => a.Description)));
List<string> TeachersOf(Guid? classId, string? name) => classId is { } id && teachersByClass.TryGetValue(id, out var byClass) && byClass.Count > 0 ? byClass : teachersByKey.GetValueOrDefault(Key(name)) ?? [];

bool Wanted(string? name, string? title)
{
if (string.IsNullOrWhiteSpace(subject))
return true;
if (name is not null)
return SubjectAliases.Match(subject, name) != SubjectMatch.None;
return SubjectAliases.Resolve(subject) is { } wanted && SubjectAliases.Resolve(title)?.Key == wanted.Key;
}

var items = new List<AssessmentItem>();
foreach (var exam in exams.Where(e => e.Date is { } d && d >= from && d <= to))
{
var name = ClassName(exam.ClassId);
if (!Wanted(name, exam.Name))
continue;
items.Add(new AssessmentItem(exam.Date!.Value, null, name, exam.Name, AssessmentKinds.Classify(exam.Name, exam.Description) ?? AssessmentKinds.Exam, "exams", TeachersOf(exam.ClassId, name), exam.Id, null) { Key = Key(name) });
}

var seen = new HashSet<(string Key, DateOnly Date, string Title)>();
foreach (var entry in agenda.Where(a => a.EntryType != AgendaEntryType.Holiday).OrderBy(a => a.Date))
{
var day = DateOnly.FromDateTime(entry.Date);
if (day < from || day > to)
continue;
var kind = AssessmentKinds.Classify(entry.Title, entry.Description) ?? (entry.EntryType == AgendaEntryType.Test ? AssessmentKinds.Other : null);
if (kind is null)
continue;
var name = ClassName(entry.ClassId) ?? SubjectAliases.Resolve(entry.Title)?.Name;
if (!Wanted(name, entry.Title))
continue;
var key = Key(name);
TimeOnly? time = entry.Date.TimeOfDay == TimeSpan.Zero ? null : TimeOnly.FromTimeSpan(entry.Date.TimeOfDay);

var title = AssessmentKinds.Classify(entry.Title, null) is null && AssessmentKinds.Classify(null, entry.Description) is not null ? $"{entry.Title}: {Cap(entry.Description!.Trim(), 120)}" : entry.Title;
var twin = items.FirstOrDefault(i => i.Source == "exams" && i.Date == day && (key.Length > 0 ? i.Key == key : SubjectAliases.Normalize(entry.Title + " " + entry.Description).Contains(SubjectAliases.Normalize(i.Title))));
if (twin is not null)
{
twin.AlsoInAgenda = true;
twin.Time ??= time;
continue;
}
if (!seen.Add((key.Length > 0 ? key : SubjectAliases.Clean(entry.Title), day, SubjectAliases.Normalize(title))))
continue;
items.Add(new AssessmentItem(day, time, name, title, kind, "agenda", TeachersOf(entry.ClassId, name), null, entry.Id) { Key = key });
}
return items.OrderBy(i => i.Date).ThenBy(i => i.Time ?? TimeOnly.MinValue).ThenBy(i => i.Subject, StringComparer.OrdinalIgnoreCase).ToList();
}
}

public sealed class AssessmentItem(DateOnly date, TimeOnly? time, string? subject, string title, string kind, string source, List<string> teachers, Guid? examId, Guid? agendaEntryId)
{
public DateOnly Date { get; } = date;
public TimeOnly? Time { get; set; } = time;
public string? Subject { get; } = subject;
public string Title { get; } = title;
public string Kind { get; } = kind;
public string Source { get; } = source;
public List<string> Teachers { get; } = teachers;
public Guid? ExamId { get; } = examId;
public Guid? AgendaEntryId { get; } = agendaEntryId;
public string Key { get; init; } = string.Empty;
public bool AlsoInAgenda { get; set; }
}
}
2 changes: 1 addition & 1 deletion src/Schuly.API/Mcp/Tools/SchulyTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ namespace Schuly.API.Mcp.Tools
{
/// <summary>The user's own school data, read through the same Mediator queries as the REST API and so under the same checks.</summary>
[McpServerToolType]
public sealed class SchulyTools(IMediator mediator, IHttpContextAccessor accessor, InProcessPluginApi plugins)
public sealed partial class SchulyTools(IMediator mediator, IHttpContextAccessor accessor, InProcessPluginApi plugins)
{
private const int MaxDocumentBytes = 2 * 1024 * 1024;
private const int MaxBase64Bytes = 256 * 1024;
Expand Down
Loading
Loading