diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index d0daf33..9d21030 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -549,6 +549,14 @@ if (hasUntis) { huge.text.split('\n')[0], ); + const prep = await client.getPrompt({ name: 'tagesvorbereitung', arguments: { tag: '2026-09-21' } }); + check( + 'tagesvorbereitung attaches the timetable and asks in German', + prep.messages.length === 2 && + /## Montag, 21\.09\.2026/.test(prep.messages[0].content.text) && + /Bereite mich auf den Schultag/.test(prep.messages[1].content.text), + prep.description, + ); } console.log('\n== api_get guard rails =='); diff --git a/src/mcp/prompts.ts b/src/mcp/prompts.ts index b126fe8..b0e5730 100644 --- a/src/mcp/prompts.ts +++ b/src/mcp/prompts.ts @@ -2,12 +2,13 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { ErrorCode, type GetPromptResult } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import type { ServerContext } from '../context.ts'; -import { germanDate } from '../core/dates.ts'; +import { addDays, germanDate, germanDay, germanWeekday, isCalendarDate, schoolToday } from '../core/dates.ts'; import { fold, joinSections, matchesAll, tokenize } from '../core/text.ts'; import { courseUri, roomUri } from './resources.ts'; import { readCourse } from './tools/content.ts'; import { readRoom } from './tools/rooms.ts'; import { ProtocolError, toProtocolError } from './tools/result.ts'; +import { readTimetable } from './tools/untis.ts'; /** * Prompts: ready-made requests a person picks from a menu, written in German @@ -81,6 +82,64 @@ export function registerPrompts(server: McpServer, context: ServerContext): void }, ); + // Only with WebUntis: without the timetable there is no way to know which + // lessons a day holds, and a "Tagesvorbereitung" that has to ask is not one. + const untis = context.untis; + if (untis) { + server.registerPrompt( + 'tagesvorbereitung', + { + title: 'Tagesvorbereitung', + description: + 'Bereitet einen Schultag vor: die Stunden aus WebUntis samt Entfall und Vertretung, dazu das Neue ' + + 'aus den passenden Kursen der Schulcloud, Fälligkeiten und angekündigte Tests.', + argsSchema: { + tag: z + .string() + .optional() + .describe('Optional: heute (Standard), morgen, übermorgen oder ein Datum wie 2026-09-21.'), + }, + }, + async ({ tag }) => { + const date = resolveDay(argumentText(tag)); + let timetable: string; + try { + timetable = await readTimetable(untis, { from: date, to: date }); + } catch (error) { + throw toProtocolError(error, `read the WebUntis timetable for ${date}`); + } + return { + description: `Tagesvorbereitung: ${germanWeekday(date)}, ${germanDay(date)}`, + messages: [ + { role: 'user', content: { type: 'text', text: timetable } }, + { role: 'user', content: { type: 'text', text: dayPrompt(date) } }, + ], + }; + }, + ); + } +} + +/** + * The day a person meant: `heute`, `morgen`, `übermorgen`, `21.09.2026` or + * `2026-09-21`. + * + * Evening preparation is the normal case for "morgen", and a German date is + * what a German keyboard produces — refusing either would make the prompt + * something to look up rather than to type. + */ +export function resolveDay(value: string | undefined, today: string = schoolToday()): string { + const raw = value?.trim().toLowerCase(); + if (!raw || raw === 'heute') return today; + if (raw === 'morgen') return addDays(today, 1); + if (raw === 'übermorgen' || raw === 'uebermorgen') return addDays(today, 2); + const german = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(raw); + if (german) return `${german[3]}-${german[2]!.padStart(2, '0')}-${german[1]!.padStart(2, '0')}`; + if (isCalendarDate(raw)) return raw; + throw new ProtocolError( + ErrorCode.InvalidParams, + `„${value}“ ist kein Tag. Nimm heute, morgen, übermorgen oder ein Datum wie 2026-09-21.`, + ); } // --- arguments ----------------------------------------------------------- @@ -283,6 +342,53 @@ export function examPrompt(target: Target, options: { topic?: string; date?: str ]); } +/** + * The morning briefing. + * + * Built around the one thing WebUntis knows and Schulcloud does not — which + * lessons actually happen — and the one thing Schulcloud knows and WebUntis + * does not: the material for them. The two lists of things to hand in stay + * separate on purpose, because a teacher uses one or the other and a merged + * list quietly drops half. + */ +export function dayPrompt(date: string): string { + return joinSections([ + `Bereite mich auf den Schultag am ${germanWeekday(date)}, ${germanDay(date)} vor. Der Stundenplan aus ` + + 'WebUntis steht oben.', + `So gehst du vor:\n${numbered([ + 'Geh die Stunden des Tages durch. Entfallene Stunden lässt du weg, bei Vertretungen zählt, was stattfindet.', + 'Ordne jeder Stunde den passenden Kurs in der Schulcloud zu (list_courses). Die Fächerkürzel und die ' + + 'Kursnamen sehen unterschiedlich aus, also geh über das Fach, nicht über eine ID.', + 'Sieh dir zu jedem dieser Kurse an, was neu ist: what_changed seit dem letzten Schultag, dazu get_course ' + + 'und das, was daran hängt (get_board, get_lesson), sowie die Kurs-Dateien (fs_tree, fs_read).', + 'Mit untis_lesson_topics und der periodId einer Stunde siehst du, was im Unterricht zuletzt behandelt ' + + 'wurde. Daran erkennst du, was als Nächstes dran ist.', + 'Prüfe, was fällig ist: list_tasks für die Schulcloud-Aufgaben und untis_homework für die Hausaufgaben ' + + 'aus dem Klassenbuch. Das sind zwei getrennte Listen.', + 'Lies die Notizen an den Stunden im Stundenplan. Angekündigte Tests und Leistungskontrollen stehen ' + + 'meistens dort und nirgends sonst.', + ])}`, + `Die Vorbereitung enthält:\n${bulleted([ + '**Der Tag:** je Stunde Zeit, Fach, Raum und Lehrkraft, bei Änderungen mit einem Wort dazu.', + '**Je Fach:** worum es zuletzt ging, was voraussichtlich dran ist, und was ich mir dafür ansehen sollte — ' + + 'jeweils mit Quelle, damit ich es wiederfinde.', + '**Vorbereiten und mitbringen:** konkrete Punkte aus den Notizen, Hausaufgaben und Aufgaben.', + '**Fällig:** Aufgaben und Hausaufgaben mit Datum, das von heute und morgen zuerst.', + '**Angekündigt:** Tests, Leistungskontrollen und Prüfungen, mit Datum und Fach.', + '**Sonstiges:** Neuigkeiten (list_news), die den Tag betreffen.', + ])}`, + `Wichtig:\n${bulleted([ + 'Wenn an dem Tag kein Unterricht ist, sag das in einem Satz, nenne den nächsten Schultag und bereite ' + + 'stattdessen kurz diesen vor.', + 'Stütze dich auf Schulcloud und WebUntis und nenne die Quelle. Was du aus eigenem Wissen ergänzt, ' + + 'kennzeichnest du.', + UNREADABLE, + 'Halte es knapp: ich lese das morgens vor der Schule.', + 'Antworte auf Deutsch.', + ])}`, + ]); +} + function numbered(items: (string | false | undefined)[]): string { return items .filter((item): item is string => Boolean(item)) diff --git a/test/prompts.test.ts b/test/prompts.test.ts index 7aade99..6f8840a 100644 --- a/test/prompts.test.ts +++ b/test/prompts.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { germanDate } from '../src/core/dates.ts'; -import { argumentText, examPrompt, resolveTarget, summaryPrompt, type Target } from '../src/mcp/prompts.ts'; +import { argumentText, dayPrompt, examPrompt, resolveDay, resolveTarget, summaryPrompt, type Target } from '../src/mcp/prompts.ts'; import { ProtocolError } from '../src/mcp/tools/result.ts'; const CANDIDATES: Target[] = [ @@ -153,3 +153,38 @@ describe('germanDate', () => { }); }); +describe('resolveDay', () => { + const today = '2026-09-17'; + + it('takes the words a person types in the evening', () => { + assert.equal(resolveDay(undefined, today), today); + assert.equal(resolveDay('heute', today), today); + assert.equal(resolveDay('morgen', today), '2026-09-18'); + assert.equal(resolveDay('Übermorgen', today), '2026-09-19'); + assert.equal(resolveDay('uebermorgen', today), '2026-09-19'); + }); + + it('takes a date in either notation, because a German keyboard writes one of them', () => { + assert.equal(resolveDay('2026-09-21', today), '2026-09-21'); + assert.equal(resolveDay('21.09.2026', today), '2026-09-21'); + assert.equal(resolveDay('1.9.2026', today), '2026-09-01'); + }); + + it('refuses anything else, in German', () => { + assert.throws(() => resolveDay('irgendwann', today), /ist kein Tag/); + assert.throws(() => resolveDay('2026-02-30', today), /ist kein Tag/); + }); +}); + +describe('dayPrompt', () => { + it('names the day and keeps the two lists of due work apart', () => { + const prompt = dayPrompt('2026-09-21'); + assert.match(prompt, /Montag, 21\.09\.2026/); + assert.match(prompt, /list_tasks/); + assert.match(prompt, /untis_homework/); + assert.match(prompt, /zwei getrennte Listen/); + // The prompt has to survive a day without lessons, which is common here. + assert.match(prompt, /kein Unterricht/); + assert.match(prompt, /Antworte auf Deutsch/); + }); +});