diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 9d21030..c998452 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -292,6 +292,9 @@ console.log('\n== file manager (Dateien) =='); } console.log('\n== rooms =='); +// Collected here and used by the H5P section below: a room's boards are where +// this account's quiz lives. +const roomBoardIds = []; // Rooms ("Räume") are a separate space from courses. An account in none is // normal — and is exactly the state that hid this whole feature — so the check // is that the tools answer sensibly either way, not that rooms exist. @@ -302,6 +305,7 @@ console.log('\n== rooms =='); if (roomId) { const room = await call('get_room', { roomId }); check('get_room opens a room', !room.isError && /Room id:/.test(room.text), roomId); + roomBoardIds.push(...[...room.text.matchAll(/\(`([0-9a-f]{24})`\)/g)].map((m) => m[1]).slice(0, 8)); const roomBoardId = room.text.match(/### Boards[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1]; if (roomBoardId) { const board = await call('get_board', { boardId: roomBoardId }); @@ -314,6 +318,47 @@ console.log('\n== rooms =='); } } +console.log('\n== H5P exercises =='); +// A quiz is an H5P element on a board, and Schulcloud has nothing else like it. +// Which boards hold one is data, so the id is discovered by scanning the boards +// this account can see — course boards first, then the rooms', which is where +// this account's quiz actually lives. +{ + let contentId; + let foundOn; + const boardsToScan = [...boardIds, ...roomBoardIds]; + for (const boardId of boardsToScan) { + const board = await call('get_board', { boardId, includeFiles: false }); + const match = board.text.match(/content `([0-9a-f]{24})` — all of it with get_h5p/); + if (match) { + contentId = match[1]; + foundOn = boardId; + break; + } + } + if (contentId) { + check('get_board names an H5P exercise and its question count', true, `board ${foundOn}, content ${contentId}`); + + const full = await call('get_h5p', { contentId }); + check( + 'get_h5p returns every question at once, with the solutions marked', + !full.isError && /^### 1\. /m.test(full.text) && /✔/.test(full.text), + full.text.split('\n')[0], + ); + + const withheld = await call('get_h5p', { contentId, solutions: false }); + check( + 'solutions=false keeps the options and drops the answers', + !withheld.isError && /^### 1\. /m.test(withheld.text) && !/✔/.test(withheld.text) && /Solutions withheld/.test(withheld.text), + withheld.text.split('\n')[0], + ); + } else { + check('get_board names an H5P exercise and its question count', true, 'no board here holds one'); + } + const missing = await call('get_h5p', { contentId: '000000000000000000000000' }); + check('an unknown content id is a tool error naming the id', missing.isError && /404/.test(missing.text), missing.text.split('\n')[0]); +} + console.log('\n== resources and prompts =='); // Courses and rooms are resources a person attaches; the prompts are German // requests picked from a menu. Both reuse the tools' reads, so what is checked diff --git a/src/core/board.ts b/src/core/board.ts index ea8e65b..f6eb971 100644 --- a/src/core/board.ts +++ b/src/core/board.ts @@ -1,6 +1,7 @@ import type { Config } from '../config.ts'; import type { SchulcloudClient } from './client.ts'; import { fetchPadText } from './etherpad.ts'; +import { readH5pContent, type H5pContent } from './h5p.ts'; import { SchulcloudApiError } from './client.ts'; import type { BoardSkeleton, CardResponse, ContentElement, FileRecord } from './types.ts'; @@ -32,6 +33,8 @@ export interface AssembledElement { alternativeText?: string; /** H5P content id: the handle onto interactive content (quizzes and the like). */ h5pContentId?: string; + /** The exercise behind that id, when it was resolved: every question at once. */ + h5p?: H5pContent; /** Which configured tool an externalTool element launches. */ contextExternalToolId?: string; /** What a deleted element used to be. */ @@ -66,7 +69,7 @@ export async function assembleBoard( client: SchulcloudClient, boardId: string, schoolId: string, - options: { resolveFiles?: boolean; resolvePads?: Config } = {}, + options: { resolveFiles?: boolean; resolvePads?: Config; resolveH5p?: boolean } = {}, ): Promise { const resolveFiles = options.resolveFiles ?? true; @@ -92,6 +95,12 @@ export async function assembleBoard( await attachPadText(options.resolvePads, assembled); } + // A quiz is course material too, and the board hands over only a contentId. + // One request per H5P element, and only when a board has one. + if (options.resolveH5p) { + await attachH5pContent(client, assembled); + } + const fileCount = assembled .flatMap((column) => column.cards) .flatMap((card) => card.elements) @@ -197,6 +206,29 @@ async function attachPadText(config: Config, columns: AssembledColumn[]): Promis ); } +/** + * Fills in the exercise behind each H5P element. + * + * Silent on failure, like a pad: the element and its content id are still + * reported, and get_h5p then says why it could not be read. + */ +async function attachH5pContent(client: SchulcloudClient, columns: AssembledColumn[]): Promise { + const elements = columns + .flatMap((column) => column.cards) + .flatMap((card) => card.elements) + .filter((element) => element.type === 'h5p' && element.h5pContentId); + + await Promise.all( + elements.map(async (element) => { + try { + element.h5p = await readH5pContent(client, element.h5pContentId!); + } catch { + // Left unset: the formatter falls back to naming the content id. + } + }), + ); +} + /** * Resolves file-bearing elements to file records. * diff --git a/src/core/client.ts b/src/core/client.ts index df62a1c..52857c2 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -450,6 +450,21 @@ export class SchulcloudClient { return this.getJson(`/api/v3/boards/${encodeURIComponent(boardId)}/context`); } + /** + * The content behind an H5P element: a quiz with all of its questions. + * + * `params` is what the player loads before it renders anything, so one GET + * returns the whole exercise — every question, every answer option and which + * of them is correct — even though the player then shows one question at a + * time. There is nothing to step through and no page to scrape. + * + * The shape belongs to whichever H5P library the content uses, so it stays + * `unknown` here and `core/h5p.ts` interprets it. + */ + getH5pParams(contentId: string): Promise { + return this.getJson(`/api/v3/h5p-editor/params/${encodeURIComponent(contentId)}`); + } + /** * Card bodies for the given ids. * diff --git a/src/core/crawl.ts b/src/core/crawl.ts index 39b5c6a..b982443 100644 --- a/src/core/crawl.ts +++ b/src/core/crawl.ts @@ -1,4 +1,5 @@ import type { Config } from '../config.ts'; +import { h5pSearchText } from './h5p.ts'; import { assembleBoard, type AssembledBoard } from './board.ts'; import type { SchulcloudClient } from './client.ts'; import { fetchHomeworkPage } from './homework-page.ts'; @@ -320,6 +321,7 @@ async function crawlBoards( assembled = await assembleBoard(client, boardId, options.schoolId, { resolveFiles: includeFiles, resolvePads: options.config, + resolveH5p: true, }); } catch (error) { // Record rather than swallow: a dropped board used to disappear from the @@ -342,6 +344,9 @@ async function crawlBoards( // Pad contents are course material like any other; without this they // are unsearchable, and a pad is often where the actual group work is. if (element.padText) parts.push(element.padText); + // A quiz's questions are material too: without this, searching for + // something a teacher only asked in an H5P exercise finds nothing. + if (element.h5p) parts.push(h5pSearchText(element.h5p)); if (element.url) parts.push(element.url); for (const record of element.files) { files.push({ diff --git a/src/core/h5p.ts b/src/core/h5p.ts new file mode 100644 index 0000000..138e735 --- /dev/null +++ b/src/core/h5p.ts @@ -0,0 +1,305 @@ +/** + * Reads H5P content: the quizzes and interactive exercises on a board. + * + * Schulcloud has no quiz of its own — an exercise is an H5P element, and the + * board API hands over nothing but a `contentId`. The player then shows one + * question at a time, which makes a quiz look like something that has to be + * stepped through or scraped. It is not: `GET /api/v3/h5p-editor/params/{id}` + * returns the entire exercise as the JSON the player is fed, so one request + * holds every question, every option and which of them is correct. + * + * What varies is the *shape* of that JSON, because it belongs to whichever H5P + * library the teacher used. The common question types are modelled below; for + * anything else the text is harvested generically and labelled as such, so an + * exercise this parser does not know still arrives readable instead of empty. + */ + +import type { SchulcloudClient } from './client.ts'; +import { htmlToText } from './text.ts'; + +export interface H5pAnswer { + text: string; + /** Undefined when the library does not say — never guess a solution. */ + correct?: boolean; + tip?: string; +} + +export interface H5pQuestion { + /** The H5P library, e.g. `H5P.MultiChoice 1.16`. */ + library: string; + /** A short human name for the kind of task, for the reader. */ + kind: string; + text: string; + answers: H5pAnswer[]; + /** True when more than one option is meant to be ticked. */ + multiple?: boolean; + /** The task's own text where it is not the question: a cloze, a description. */ + body?: string; +} + +export interface H5pContent { + contentId: string; + title: string; + /** The main library of the content itself, e.g. `H5P.QuestionSet`. */ + library: string; + intro?: string; + /** Percentage needed to pass, when the content sets one. */ + passPercentage?: number; + questions: H5pQuestion[]; + /** + * Text from parts this parser does not model. Never silently dropped: a + * teacher's exercise turning up as "0 questions" is the bug this avoids. + */ + unmodelled: string[]; +} + +/** Fetches and parses one H5P content. Throws if it cannot be read or parsed. */ +export async function readH5pContent(client: SchulcloudClient, contentId: string): Promise { + return parseH5pParams(contentId, await client.getH5pParams(contentId)); +} + +/** Everything in the content as one string, for the search index. */ +export function h5pSearchText(content: H5pContent): string { + const parts = [content.title, content.intro]; + for (const question of content.questions) { + parts.push(question.text, question.body, ...question.answers.map((answer) => answer.text)); + } + parts.push(...content.unmodelled); + return parts.filter((part): part is string => Boolean(part?.trim())).join('\n'); +} + +/** + * Interprets a `params` payload. + * + * Throws on a payload that is not H5P at all rather than returning an empty + * exercise — the difference between "this quiz has no questions" and "the + * format changed" matters, and only one of them is worth reporting as content. + */ +export function parseH5pParams(contentId: string, payload: unknown): H5pContent { + const root = asRecord(payload); + const metadata = asRecord(root?.h5p); + // `params.params` is the content itself; `params.metadata` beside it repeats + // the H5P metadata. A single-question content has the same shape. + const outer = asRecord(root?.params); + const params = asRecord(outer?.params) ?? outer; + const library = string(metadata?.mainLibrary) ?? string(root?.library) ?? 'unknown'; + if (!params) { + throw new Error(`H5P content ${contentId} carried no params — the payload shape has changed`); + } + + const title = string(metadata?.title)?.trim() || 'H5P content'; + const content: H5pContent = { contentId, title, library, questions: [], unmodelled: [] }; + + const introPage = asRecord(params.introPage); + const intro = htmlToText(string(introPage?.introduction) ?? string(params.intro) ?? '').trim(); + if (intro) content.intro = intro; + if (typeof params.passPercentage === 'number') content.passPercentage = params.passPercentage; + + // A QuestionSet holds a list of sub-contents, each with its own library; any + // other library *is* the single question. + const questions = Array.isArray(params.questions) && params.questions.some((entry) => asRecord(entry)?.library) + ? params.questions + : undefined; + if (questions) { + for (const entry of questions) { + const record = asRecord(entry); + const sub = asRecord(record?.params); + const subLibrary = string(record?.library) ?? 'unknown'; + if (sub) content.questions.push(...parseQuestion(subLibrary, sub, content.unmodelled)); + } + } else { + content.questions.push(...parseQuestion(library, params, content.unmodelled)); + } + + return content; +} + +/** + * One sub-content as questions. + * + * Returns several for the libraries that bundle them (SingleChoiceSet, + * Summary, Blanks) and none for one it cannot read, in which case its text goes + * to `unmodelled`. + */ +function parseQuestion(library: string, params: Record, unmodelled: string[]): H5pQuestion[] { + const name = library.split(/\s+/)[0] ?? library; + const question = (text: string, answers: H5pAnswer[], extra: Partial = {}): H5pQuestion => ({ + library, + kind: KIND[name] ?? name.replace(/^H5P\./, ''), + text, + answers, + ...extra, + }); + + switch (name) { + case 'H5P.MultiChoice': { + const answers = (asArray(params.answers) ?? []).map((entry): H5pAnswer => { + const answer = asRecord(entry); + const tip = htmlToText(string(asRecord(answer?.tipsAndFeedback)?.tip) ?? '').trim(); + return { + text: htmlToText(string(answer?.text) ?? '').trim(), + correct: answer?.correct === true, + ...(tip ? { tip } : {}), + }; + }); + // `singleAnswer` is what the player uses to choose radio buttons over + // checkboxes, and it is the only honest way to say "tick one". + const single = asRecord(params.behaviour)?.singleAnswer === true; + return [ + question(htmlToText(string(params.question) ?? '').trim(), answers, { + multiple: !single && answers.filter((answer) => answer.correct).length !== 1, + }), + ]; + } + case 'H5P.TrueFalse': { + const l10n = asRecord(params.l10n); + const yes = string(l10n?.trueText) ?? 'Wahr'; + const no = string(l10n?.falseText) ?? 'Falsch'; + // `correct` is the string "true" or "false", not a boolean. + const correct = string(params.correct); + return [ + question(htmlToText(string(params.question) ?? '').trim(), [ + { text: yes, correct: correct === 'true' }, + { text: no, correct: correct === 'false' }, + ]), + ]; + } + case 'H5P.Blanks': { + const description = htmlToText(string(params.text) ?? '').trim(); + return (asArray(params.questions) ?? []).flatMap((entry) => { + const raw = string(entry); + if (!raw) return []; + const { text, answers } = parseCloze(raw); + return [question(text, answers, { body: description || undefined })]; + }); + } + case 'H5P.DragText': + case 'H5P.MarkTheWords': { + const raw = string(params.textField) ?? ''; + const { text, answers } = parseCloze(raw); + const description = htmlToText(string(params.taskDescription) ?? '').trim(); + return [question(description || 'Aufgabe', answers, { body: text })]; + } + case 'H5P.SingleChoiceSet': { + return (asArray(params.choices) ?? []).flatMap((entry) => { + const choice = asRecord(entry); + const texts = (asArray(choice?.answers) ?? []).map((answer) => htmlToText(string(answer) ?? '').trim()); + // The first option is the correct one; the player shuffles them. + const answers = texts.map((text, index): H5pAnswer => ({ text, correct: index === 0 })); + return [question(htmlToText(string(choice?.question) ?? '').trim(), answers)]; + }); + } + case 'H5P.Summary': { + return (asArray(params.summaries) ?? []).flatMap((entry) => { + const group = asRecord(entry); + const texts = (asArray(group?.summary) ?? []).map((item) => htmlToText(string(item) ?? '').trim()); + const answers = texts.map((text, index): H5pAnswer => ({ text, correct: index === 0 })); + return [question(htmlToText(string(params.intro) ?? '').trim() || 'Welche Aussage stimmt?', answers)]; + }); + } + case 'H5P.Column': { + // A column stacks sub-contents; each carries its own library. + return (asArray(params.content) ?? []).flatMap((entry) => { + const inner = asRecord(asRecord(entry)?.content); + const innerParams = asRecord(inner?.params); + const innerLibrary = string(inner?.library) ?? 'unknown'; + return innerParams ? parseQuestion(innerLibrary, innerParams, unmodelled) : []; + }); + } + default: { + const harvested = harvest(params); + if (harvested.length > 0) unmodelled.push(`${name}: ${harvested.join(' | ')}`); + return []; + } + } +} + +/** Short names for the libraries worth naming; anything else keeps its own. */ +const KIND: Record = { + 'H5P.MultiChoice': 'multiple choice', + 'H5P.TrueFalse': 'true/false', + 'H5P.Blanks': 'fill in the blanks', + 'H5P.DragText': 'drag the words', + 'H5P.MarkTheWords': 'mark the words', + 'H5P.SingleChoiceSet': 'single choice', + 'H5P.Summary': 'pick the correct statement', +}; + +/** + * A cloze text: H5P marks the solutions inline as `*answer:tip*`, with + * alternatives separated by slashes. + */ +export function parseCloze(raw: string): { text: string; answers: H5pAnswer[] } { + const answers: H5pAnswer[] = []; + const text = htmlToText( + raw.replace(/\*([^*]+)\*/g, (_, body: string) => { + const [solutions, tip] = body.split(':'); + const alternatives = (solutions ?? '').split('/').map((part) => part.trim()).filter(Boolean); + answers.push({ + text: alternatives.join(' / '), + correct: true, + ...(tip?.trim() ? { tip: tip.trim() } : {}), + }); + return `____ (${answers.length})`; + }), + ).trim(); + return { text, answers }; +} + +/** + * Every bit of task text in an unmodelled library. + * + * Deliberately blunt — it cannot know which field is the question — but it + * skips the subtrees that hold button labels and display settings, which + * otherwise drown the content in "Überprüfen" and "Wiederholen". + */ +const SKIP_KEYS = new Set([ + 'UI', + 'l10n', + 'behaviour', + 'overallFeedback', + 'confirmCheck', + 'confirmRetry', + 'media', + 'localization', + 'a11y', + 'accessibility', + 'scoreBarLabel', + 'texts', + 'endGame', + 'override', +]); + +function harvest(value: unknown, depth = 0, out: string[] = []): string[] { + if (depth > 6 || out.length > 40) return out; + if (typeof value === 'string') { + const text = htmlToText(value).trim(); + // Two characters of prose, not a colour code or a library version. + if (text.length > 2 && /\p{L}{2}/u.test(text) && !out.includes(text)) out.push(text); + return out; + } + if (Array.isArray(value)) { + for (const item of value) harvest(item, depth + 1, out); + return out; + } + const record = asRecord(value); + if (record) { + for (const [key, item] of Object.entries(record)) { + if (SKIP_KEYS.has(key)) continue; + harvest(item, depth + 1, out); + } + } + return out; +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : undefined; +} + +function asArray(value: unknown): unknown[] | undefined { + return Array.isArray(value) ? value : undefined; +} + +function string(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} diff --git a/src/core/match.ts b/src/core/match.ts index 31118b6..64b849b 100644 --- a/src/core/match.ts +++ b/src/core/match.ts @@ -1,4 +1,5 @@ import type { CrawledBoard, Snapshot } from './crawl.ts'; +import { h5pSearchText } from './h5p.ts'; import { matchesAll, snippet, tokenize } from './text.ts'; /** @@ -44,6 +45,8 @@ function matchBoards( // Pad contents are searched by the index; without this the live // crawl path would quietly disagree with it. if (element.padText) parts.push(element.padText); + // Same corpus as the index, or a live search would disagree with it. + if (element.h5p) parts.push(h5pSearchText(element.h5p)); if (element.url) parts.push(element.url); for (const file of element.files) parts.push(file.name); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 21f5c41..f3def33 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -7,6 +7,7 @@ import { registerResources } from './resources.ts'; import { registerContentTools } from './tools/content.ts'; import { registerFileTools } from './tools/files.ts'; import { registerFilesystemTools } from './tools/filesystem.ts'; +import { registerH5pTools } from './tools/h5p.ts'; import { registerOverviewTools } from './tools/overview.ts'; import { registerRawTool } from './tools/raw.ts'; import { registerIndexTools } from './tools/index-tools.ts'; @@ -28,6 +29,9 @@ How the content is organised, and the usual path through it: text block, link and attached file in one call. - **Topics / lessons** ("Themen") — the older format. get_lesson. - **Tasks** ("Aufgaben") — homework. list_tasks across all courses, get_task for one. +- **Quizzes and interactive exercises** are H5P elements on a board ("Quiz", "Test", "Übung"). get_board names + one and says how many questions it holds; get_h5p returns all of them with the correct options marked — the + player steps through one at a time, this does not. search finds their question text too. - **Files** hang off boards, lessons and tasks. Every listing shows file ids; download_file fetches one and extracts its text (PDF, Word, Excel, PowerPoint, OpenDocument) or returns an image inline. - **The file manager ("Dateien")** is a separate store with a real folder tree, browsed with the fs_* tools: @@ -72,6 +76,7 @@ export function createServer(config: Config, services?: Services): { server: Mcp registerRoomTools(server, context); registerFileTools(server, context); registerFilesystemTools(server, context); + registerH5pTools(server, context); registerSearchTool(server, context); registerSubmissionTools(server, context); registerIndexTools(server, context); diff --git a/src/mcp/tools/content.ts b/src/mcp/tools/content.ts index 416dd2f..f57ba4f 100644 --- a/src/mcp/tools/content.ts +++ b/src/mcp/tools/content.ts @@ -69,6 +69,7 @@ export function registerContentTools(server: McpServer, context: ServerContext): const board = await assembleBoard(context.client, boardId, schoolId, { resolveFiles: includeFiles, resolvePads: context.config, + resolveH5p: true, }); return text(formatBoard(board, includeFiles)); } catch (error) { @@ -478,8 +479,21 @@ function formatElement(element: AssembledElement, includeFiles: boolean): string case 'h5p': { // Schulcloud has no quiz of its own: interactive exercises are H5P, and // this id is the only way to reach the content behind one. - const content = element.h5pContentId ? ` — H5P content \`${element.h5pContentId}\`` : ''; - return `- H5P interactive content \`${element.id}\`${content}`; + const content = element.h5pContentId ? ` \`${element.h5pContentId}\`` : ''; + if (!element.h5p) { + return `- H5P interactive content \`${element.id}\`${content ? ` — H5P content${content}` : ''}`; + } + // Summarised rather than inlined: a question set runs to twenty + // questions with their options, which would bury the rest of the board. + // get_h5p prints them, and search reaches their text either way. + const quiz = element.h5p; + const kinds = [...new Set(quiz.questions.map((question) => question.kind))].join(', '); + const count = quiz.questions.length; + const unread = quiz.unmodelled.length > 0 ? `, ${quiz.unmodelled.length} part(s) this server cannot model` : ''; + return ( + `- **H5P exercise: ${quiz.title}** — ${count} question(s)${kinds ? ` (${kinds})` : ''}${unread}, ` + + `content${content} — all of it with get_h5p` + ); } case 'deleted': { // Saying what it was beats "(deleted element)": the title often names diff --git a/src/mcp/tools/h5p.ts b/src/mcp/tools/h5p.ts new file mode 100644 index 0000000..9f1b891 --- /dev/null +++ b/src/mcp/tools/h5p.ts @@ -0,0 +1,93 @@ +/** + * The H5P tool: a quiz in full, rather than one question at a time. + * + * The board tells you an exercise exists and how many questions it holds; this + * prints them. Kept out of the board on purpose — twenty questions with their + * options would bury everything else on it. + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { ServerContext } from '../../context.ts'; +import { heading, joinSections } from '../../core/text.ts'; +import { readH5pContent, type H5pContent, type H5pQuestion } from '../../core/h5p.ts'; +import { text, toToolError } from './result.ts'; + +const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }; + +export function registerH5pTools(server: McpServer, context: ServerContext): void { + server.registerTool( + 'get_h5p', + { + title: 'Get H5P exercise', + description: + 'An interactive exercise in full: every question, every option, and which of them is correct. This ' + + 'is how to read a quiz ("Quiz", "Test", "Übung") that a teacher built into a board — Schulcloud has ' + + 'no quiz of its own, so these are H5P elements, and get_board lists them with the content id this ' + + 'takes. The player shows one question at a time; this returns all of them at once, so there is ' + + 'nothing to step through. Pass solutions=false to get the questions without the answers, for asking ' + + 'the user them one by one.', + inputSchema: { + contentId: z + .string() + .describe('H5P content id, as get_board prints it for an H5P element (not the element id).'), + solutions: z + .boolean() + .default(true) + .describe('Mark the correct options. Turn off to quiz the user without giving the answers away.'), + }, + annotations: READ_ONLY, + }, + async ({ contentId, solutions }) => { + try { + return text(formatH5p(await readH5pContent(context.client, contentId), solutions)); + } catch (error) { + return toToolError(error, `read H5P content ${contentId}`); + } + }, + ); +} + +export function formatH5p(content: H5pContent, solutions: boolean): string { + const facts = [ + `- Content id: \`${content.contentId}\``, + `- Type: ${content.library}`, + `- Questions: ${content.questions.length}`, + content.passPercentage !== undefined ? `- Pass mark: ${content.passPercentage}%` : undefined, + solutions ? undefined : '- _Solutions withheld: call again with solutions=true to see them._', + ].filter(Boolean); + + return joinSections([ + heading(2, content.title), + facts.join('\n'), + content.intro, + ...content.questions.map((question, index) => formatQuestion(question, index + 1, solutions)), + content.questions.length === 0 && content.unmodelled.length === 0 + ? '_This exercise holds no questions this server can read._' + : undefined, + // Never silently dropped: an exercise type this server does not model + // still has its text reported, labelled for what it is. + content.unmodelled.length > 0 + ? joinSections([ + heading(3, 'Parts this server cannot model'), + '_Read as plain text, so the structure of these is lost:_', + content.unmodelled.map((part) => `- ${part}`).join('\n'), + ]) + : undefined, + ]); +} + +function formatQuestion(question: H5pQuestion, number: number, solutions: boolean): string { + const hint = question.multiple ? ', several correct' : ''; + const label = `${number}. ${question.text || '(no question text)'}`; + const options = question.answers.map((answer) => { + const tip = answer.tip ? ` _(Hinweis: ${answer.tip})_` : ''; + if (!solutions || answer.correct === undefined) return `- ${answer.text}${tip}`; + return answer.correct ? `- **${answer.text}** ✔${tip}` : `- ${answer.text}${tip}`; + }); + return joinSections([ + heading(3, `${label} _(${question.kind}${hint})_`), + question.body, + options.length > 0 ? options.join('\n') : '_(no options)_', + ]); +} diff --git a/test/h5p.test.ts b/test/h5p.test.ts new file mode 100644 index 0000000..2661168 --- /dev/null +++ b/test/h5p.test.ts @@ -0,0 +1,217 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { h5pSearchText, parseCloze, parseH5pParams } from '../src/core/h5p.ts'; +import { formatH5p } from '../src/mcp/tools/h5p.ts'; + +/** The shape the live instance returns: metadata, then params.params. */ +function payload(params: unknown, mainLibrary = 'H5P.QuestionSet', title = 'Quiz zur DIN 5008') { + return { h5p: { title, mainLibrary }, library: `${mainLibrary} 1.20`, params: { metadata: { title }, params } }; +} + +const MULTI_CHOICE = { + library: 'H5P.MultiChoice 1.16', + params: { + question: '

Wie richtet man Zahlen in Tabellen aus?

', + answers: [ + { text: '
Linksbündig
', correct: false, tipsAndFeedback: { tip: '' } }, + { text: 'Rechtsbündig', correct: true, tipsAndFeedback: { tip: 'Denk an die Nachkommastellen' } }, + ], + behaviour: { singleAnswer: true }, + UI: { checkAnswerButton: 'Überprüfen', tryAgainButton: 'Wiederholen' }, + }, +}; + +describe('parseH5pParams: question sets', () => { + it('reads a question set as the player is fed it', () => { + const content = parseH5pParams('c1', payload({ passPercentage: 50, questions: [MULTI_CHOICE, MULTI_CHOICE] })); + assert.equal(content.title, 'Quiz zur DIN 5008'); + assert.equal(content.library, 'H5P.QuestionSet'); + assert.equal(content.passPercentage, 50); + assert.equal(content.questions.length, 2); + assert.equal(content.contentId, 'c1'); + }); + + it('strips the HTML a teacher types and keeps which option is correct', () => { + const [question] = parseH5pParams('c1', payload({ questions: [MULTI_CHOICE] })).questions; + assert.equal(question?.text, 'Wie richtet man Zahlen in Tabellen aus?'); + assert.equal(question?.kind, 'multiple choice'); + assert.deepEqual( + question?.answers.map((answer) => [answer.text, answer.correct]), + [ + ['Linksbündig', false], + ['Rechtsbündig', true], + ], + ); + assert.equal(question?.answers[1]?.tip, 'Denk an die Nachkommastellen'); + // singleAnswer is the player's radio-button flag: tick exactly one. + assert.equal(question?.multiple, false); + }); + + it('marks several-correct questions as such', () => { + const answers = [ + { text: 'A', correct: true }, + { text: 'B', correct: true }, + { text: 'C', correct: false }, + ]; + const [question] = parseH5pParams( + 'c1', + payload({ questions: [{ library: 'H5P.MultiChoice 1.16', params: { question: 'Was gilt?', answers } }] }), + ).questions; + assert.equal(question?.multiple, true); + }); + + it('reads a single-question content, where the params are the question', () => { + const content = parseH5pParams('c2', payload(MULTI_CHOICE.params, 'H5P.MultiChoice', 'Einzelfrage')); + assert.equal(content.questions.length, 1); + assert.equal(content.questions[0]?.text, 'Wie richtet man Zahlen in Tabellen aus?'); + }); + + it('throws on a payload that is not H5P, rather than reporting an empty quiz', () => { + assert.throws(() => parseH5pParams('c3', { nope: true }), /payload shape has changed/); + assert.throws(() => parseH5pParams('c3', 'not json'), /payload shape has changed/); + }); +}); + +describe('parseH5pParams: other question types', () => { + it('reads true/false, whose answer is the string "true"', () => { + const content = parseH5pParams( + 'c4', + payload({ + questions: [ + { + library: 'H5P.TrueFalse 1.8', + params: { question: 'Seitenzahlen gehören aufs Deckblatt.', correct: 'false', l10n: { trueText: 'Wahr', falseText: 'Falsch' } }, + }, + ], + }), + ); + assert.equal(content.questions[0]?.kind, 'true/false'); + assert.deepEqual( + content.questions[0]?.answers.map((answer) => [answer.text, answer.correct]), + [ + ['Wahr', false], + ['Falsch', true], + ], + ); + }); + + it('reads a cloze, whose solutions are marked inline', () => { + const content = parseH5pParams( + 'c5', + payload({ + questions: [ + { + library: 'H5P.Blanks 1.14', + params: { text: '

Ergänze:

', questions: ['

Die Norm heißt DIN *5008:fünf-null-null-acht*.

'] }, + }, + ], + }), + ); + const [question] = content.questions; + assert.equal(question?.kind, 'fill in the blanks'); + assert.equal(question?.text, 'Die Norm heißt DIN ____ (1).'); + assert.deepEqual(question?.answers, [{ text: '5008', correct: true, tip: 'fünf-null-null-acht' }]); + assert.equal(question?.body, 'Ergänze:'); + }); + + it('reads single choice and summary, where the first option is the correct one', () => { + const single = parseH5pParams( + 'c6', + payload({ choices: [{ question: '

Welche Schriftgröße?

', answers: ['11 pt', '9 pt'] }] }, 'H5P.SingleChoiceSet'), + ); + assert.deepEqual( + single.questions[0]?.answers.map((answer) => [answer.text, answer.correct]), + [ + ['11 pt', true], + ['9 pt', false], + ], + ); + + const summary = parseH5pParams( + 'c7', + payload({ intro: 'Wähle die richtige Aussage', summaries: [{ summary: ['Stimmt', 'Stimmt nicht'] }] }, 'H5P.Summary'), + ); + assert.equal(summary.questions[0]?.text, 'Wähle die richtige Aussage'); + assert.equal(summary.questions[0]?.answers[0]?.correct, true); + }); + + it('walks into a column of sub-contents', () => { + const content = parseH5pParams( + 'c8', + payload({ content: [{ content: MULTI_CHOICE }, { content: MULTI_CHOICE }] }, 'H5P.Column'), + ); + assert.equal(content.questions.length, 2); + }); + + it('reports the text of a type it cannot model instead of dropping it', () => { + const content = parseH5pParams( + 'c9', + payload({ + questions: [ + { + library: 'H5P.InteractiveVideo 1.27', + params: { + interactiveVideo: { summary: { task: { params: { intro: 'Sieh dir das Video zur DIN 5008 an' } } } }, + // Button labels and colours must not drown the content. + UI: { play: 'Abspielen' }, + l10n: { close: 'Schließen' }, + behaviour: { autoplay: false }, + }, + }, + ], + }), + ); + assert.equal(content.questions.length, 0); + assert.equal(content.unmodelled.length, 1); + assert.match(content.unmodelled[0]!, /^H5P\.InteractiveVideo: /); + assert.match(content.unmodelled[0]!, /Sieh dir das Video zur DIN 5008 an/); + assert.doesNotMatch(content.unmodelled[0]!, /Abspielen|Schließen/); + }); +}); + +describe('parseCloze', () => { + it('numbers the blanks and keeps alternatives', () => { + const { text, answers } = parseCloze('Setze *ein/hinein* und *zwei*.'); + assert.equal(text, 'Setze ____ (1) und ____ (2).'); + assert.deepEqual( + answers.map((answer) => answer.text), + ['ein / hinein', 'zwei'], + ); + }); +}); + +describe('h5pSearchText', () => { + it('carries the questions and the options, so search can find them', () => { + const content = parseH5pParams('c1', payload({ questions: [MULTI_CHOICE] })); + const text = h5pSearchText(content); + assert.match(text, /Wie richtet man Zahlen in Tabellen aus\?/); + assert.match(text, /Rechtsbündig/); + assert.match(text, /Quiz zur DIN 5008/); + }); +}); + +describe('formatH5p', () => { + const content = parseH5pParams('c1', payload({ passPercentage: 50, questions: [MULTI_CHOICE] })); + + it('marks the solution and names the exercise', () => { + const out = formatH5p(content, true); + assert.match(out, /## Quiz zur DIN 5008/); + assert.match(out, /- Pass mark: 50%/); + assert.match(out, /\*\*Rechtsbündig\*\* ✔/); + assert.match(out, /Hinweis: Denk an die Nachkommastellen/); + }); + + it('withholds the solutions when asked, and says that it did', () => { + const out = formatH5p(content, false); + assert.doesNotMatch(out, /✔/); + assert.doesNotMatch(out, /\*\*Rechtsbündig\*\*/); + assert.match(out, /Solutions withheld/); + // The options still have to be there, or there is nothing to ask. + assert.match(out, /- Rechtsbündig/); + }); + + it('says so when there is nothing it can read', () => { + const empty = parseH5pParams('c10', payload({ questions: [] })); + assert.match(formatH5p(empty, true), /no questions this server can read/); + }); +});