Files
Schulcloud-MCP/src/core/text.ts
MechaCat02 a8badc2fd1 Read submitted text and teacher feedback from the homework page
You were right that this data never reaches the browser as an API call.
The legacy front end calls the Feathers API server-side for
submission.comment, submission.grade and submission.gradeComment and
renders them into GET /homework/{taskId}. That Feathers API is not
exposed publicly — /api/v1/* 404s — so the rendered page is the only way
to reach these fields from outside.

core/homework-page.ts parses it, hooked on the data-testid attributes
the project's own e2e tests use rather than incidental markup. The page
authenticates by jwt *cookie*; an Authorization header is ignored and
redirects to the identity provider. Every field is optional and parse
failures return undefined, so a markup change degrades to "not found"
and cannot break get_task. The wording distinguishes the two: absent
feedback is reported as not found, never as none given.

Measured on one course: 4 of 7 graded submissions carry feedback no API
call can return — "vollständig und nachvollziehbar", "Feedback siehe
Zettel", and so on.

This exposed a bug in a shared utility: htmlToText decoded only six
entities, so any named entity passed through raw. German content makes
that routine — "vollständig" would have reached the model verbatim
from boards and task descriptions too, not just here. It now decodes
named, decimal and hex references in one pass, so ä stays
literal instead of decoding twice, and leaves unknown names alone rather
than mangling them.

Also fixes a documented-recovery bug found while restoring the session:
`docker compose restart` does not re-read env_file, so it silently kept
serving the dead token. `up -d` is correct and the docs said the wrong
thing.

78 tests, 38/38 smoke; verified end to end through Claude Code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 13:25:35 +02:00

163 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Formatting helpers shared by the tools.
*
* Tool results are read by a model, so everything renders to compact Markdown
* rather than raw JSON: ids stay visible (Claude needs them for follow-up
* calls) but the surrounding noise — display colours, positions, buffer-shaped
* Mongo ids — is dropped.
*/
/**
* Named HTML entities worth decoding.
*
* Not the full HTML5 table — that is ~2000 entries for a handful this content
* actually uses. German umlauts are the ones that matter: the legacy web pages
* emit `&auml;` rather than UTF-8, so without these a grade comment comes back
* as "vollst&auml;ndig".
*/
const NAMED_ENTITIES: Record<string, string> = {
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ',
auml: 'ä', ouml: 'ö', uuml: 'ü', Auml: 'Ä', Ouml: 'Ö', Uuml: 'Ü', szlig: 'ß',
aacute: 'á', agrave: 'à', acirc: 'â', eacute: 'é', egrave: 'è', ecirc: 'ê',
iacute: 'í', igrave: 'ì', oacute: 'ó', ograve: 'ò', ocirc: 'ô',
uacute: 'ú', ugrave: 'ù', ccedil: 'ç', ntilde: 'ñ',
euro: '€', pound: '£', deg: '°', middot: '·', bull: '•', hellip: '…',
ndash: '', mdash: '—', laquo: '«', raquo: '»',
lsquo: '\u2018', rsquo: '\u2019', ldquo: '\u201c', rdquo: '\u201d', bdquo: '\u201e',
times: '×', divide: '÷', plusmn: '±', copy: '©', reg: '®', trade: '™', shy: '',
};
/**
* Decodes HTML entities: named, decimal and hexadecimal.
*
* `&amp;` is handled by the same pass as everything else rather than last,
* so `&amp;auml;` stays the literal text `&auml;` instead of being decoded
* twice into `ä`.
*/
export function decodeEntities(value: string): string {
return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]{1,31});/g, (whole, body: string) => {
if (body.startsWith('#')) {
const code = body[1] === 'x' || body[1] === 'X'
? Number.parseInt(body.slice(2), 16)
: Number.parseInt(body.slice(1), 10);
if (!Number.isFinite(code) || code <= 0 || code > 0x10ffff) return whole;
try {
return String.fromCodePoint(code);
} catch {
return whole;
}
}
// Unknown names are left alone: mangling them loses information.
return NAMED_ENTITIES[body] ?? whole;
});
}
/** Collapses Schulcloud's CKEditor HTML into plain text, keeping link targets. */
export function htmlToText(html: string | undefined | null): string {
if (!html) return '';
return decodeEntities(
html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|h[1-6]|li|tr)>/gi, '\n')
.replace(/<li[^>]*>/gi, '- ')
// Keep the href when the anchor text does not already contain it.
.replace(/<a\b[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, (_, href: string, label: string) => {
const text = label.replace(/<[^>]+>/g, '').trim();
if (!text) return href;
return text === href ? href : `${text} (${href})`;
})
.replace(/<[^>]+>/g, ''),
)
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/** `2026-08-17T08:00:00.000Z` → `2026-08-17 08:00`; passes other values through. */
export function formatDate(value: string | null | undefined): string {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toISOString().replace('T', ' ').slice(0, 16);
}
/** Days from now until `value`; negative when overdue. `undefined` if unset. */
export function daysUntil(value: string | null | undefined): number | undefined {
if (!value) return undefined;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return undefined;
return Math.round((date.getTime() - Date.now()) / 86_400_000);
}
export function dueLabel(dueDate: string | null | undefined): string {
const days = daysUntil(dueDate);
if (days === undefined) return 'no due date';
if (days < 0) return `due ${formatDate(dueDate)} (${Math.abs(days)}d overdue)`;
if (days === 0) return `due ${formatDate(dueDate)} (today)`;
return `due ${formatDate(dueDate)} (in ${days}d)`;
}
export function heading(level: number, text: string): string {
return `${'#'.repeat(level)} ${text}`;
}
/** Joins sections, dropping empties, with exactly one blank line between them. */
export function joinSections(parts: (string | undefined | null | false)[]): string {
return parts.filter((part): part is string => Boolean(part && part.trim())).join('\n\n');
}
/**
* Mongo ObjectIds sometimes come back from the legacy lesson API serialised as
* `{ buffer: { type: 'Buffer', data: [...] } }` instead of a hex string.
*/
export function normalizeObjectId(value: unknown): string | undefined {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') {
const data = (value as { buffer?: { data?: unknown } }).buffer?.data;
if (Array.isArray(data)) {
return data.map((byte) => Number(byte).toString(16).padStart(2, '0')).join('');
}
}
return undefined;
}
// --- search text utilities ---------------------------------------------
/**
* Lowercases and strips diacritics, so a query typed without umlauts still
* matches. German content makes this non-optional: "Verschlusselung" has to
* find "Verschlüsselung", and ß has to fold to ss.
*/
export function fold(value: string): string {
return value
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/ß/g, 'ss')
.toLowerCase();
}
/** Splits a query into searchable terms, dropping punctuation and stray letters. */
export function tokenize(query: string): string[] {
return fold(query)
.split(/[^\p{L}\p{N}]+/u)
.filter((token) => token.length >= 2);
}
/** True when every term appears somewhere in `haystack`. */
export function matchesAll(haystack: string | undefined, terms: string[]): boolean {
if (!haystack) return false;
const folded = fold(haystack);
return terms.every((term) => folded.includes(term));
}
/** A one-line excerpt centred on the first matching term. */
export function snippet(haystack: string, terms: string[], width = 180): string {
const flat = haystack.replace(/\s+/g, ' ').trim();
const folded = fold(flat);
const at = terms.map((term) => folded.indexOf(term)).filter((index) => index >= 0);
const centre = at.length > 0 ? Math.min(...at) : 0;
const start = Math.max(0, Math.floor(centre - width / 3));
const excerpt = flat.slice(start, start + width);
return `${start > 0 ? '…' : ''}${excerpt}${start + width < flat.length ? '…' : ''}`;
}