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>
This commit is contained in:
2026-09-13 13:25:35 +02:00
parent ac08e17b48
commit a8badc2fd1
10 changed files with 355 additions and 28 deletions

View File

@@ -25,10 +25,12 @@ How the content is organised, and the usual path through it:
- **Tasks** ("Aufgaben") — homework. list_tasks across all courses, get_task for one.
- **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.
- **Submissions** ("Abgaben") — what the user handed in. get_task shows the submission for that task;
list_submissions surveys them. Only files, the graded flag and a numeric grade are available: the API
exposes no submitted text and no written feedback, so say so rather than implying none was given.
On a teacher account these tools report other people's submissions too.
- **Submissions** ("Abgaben") — what the user handed in. get_task shows that task's submission: the files,
the graded flag, the grade, what the user wrote, and the teacher's written feedback. list_submissions
surveys them across tasks ("what is still ungraded?"). The written parts are read from the web page
because no API exposes them, so they can be missing even when feedback exists — if none is shown for a
graded submission, say it was not found rather than that none was given. On a teacher account these
tools report other people's submissions too.
When the user names a topic rather than a course, use search — the API has no search endpoint, so it walks the
courses and matches client-side, which takes a few seconds but covers board text and file names.

View File

@@ -98,7 +98,8 @@ export function registerContentTools(server: McpServer, context: ServerContext):
title: 'Get task',
description:
'Full detail for one task: description, due date, attached files, and **what the account handed ' +
'in** — submission id, graded state, grade, and the submitted files ready for download_file. ' +
'in** — submission id, graded state, grade, the submitted files ready for download_file, what ' +
'the user wrote, and the teacher\'s written feedback. ' +
'The API has no single-task endpoint, so this locates the task through the task lists and course ' +
'pages — pass courseId when you know it, which makes the lookup immediate instead of a scan.',
inputSchema: {

View File

@@ -2,6 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { ServerContext } from '../../context.ts';
import { forEachLimited } from '../../core/crawl.ts';
import { fetchSubmissionDetail } from '../../core/homework-page.ts';
import { dueLabel, heading, joinSections } from '../../core/text.ts';
import type { FileRecord, SubmissionStatus, TaskContent } from '../../core/types.ts';
import { formatFileLine } from './content.ts';
@@ -92,6 +93,11 @@ export function registerSubmissionTools(server: McpServer, context: ServerContex
);
}
/** True when at least one of these submissions is marked graded. */
function anyGraded(statuses: SubmissionStatus[]): boolean {
return statuses.some((status) => status.isGraded);
}
function formatRow({ task, status }: { task: TaskContent; status: SubmissionStatus }): string {
const state = status.isSubmitted ? 'submitted' : 'not submitted';
const graded = status.isGraded
@@ -144,9 +150,13 @@ export async function describeSubmission(
context: ServerContext,
taskId: string,
): Promise<string | undefined> {
const [me, statuses] = await Promise.all([
const [me, statuses, detail] = await Promise.all([
context.me(),
context.client.listSubmissionStatuses(taskId).catch(() => [] as SubmissionStatus[]),
// Submitted text and written feedback exist only in the rendered web page;
// see core/homework-page.ts. Optional by construction — a failure here
// costs detail, not the whole answer.
fetchSubmissionDetail(context.config, taskId).catch(() => undefined),
]);
if (statuses.length === 0) return undefined;
@@ -156,10 +166,13 @@ export async function describeSubmission(
const parts: string[] = [];
for (const status of relevant) {
const files = await loadSubmissionFiles(context, status.id);
const percent = detail?.gradePercent;
const graded = status.isGraded
? status.grade !== null && status.grade !== undefined
? `graded **${status.grade}**`
: 'graded (no numeric grade recorded)'
: percent !== undefined
? `graded **${percent}%**`
: 'graded (no numeric grade recorded)'
: 'not graded yet';
parts.push(
@@ -180,12 +193,26 @@ export async function describeSubmission(
);
}
// Only the rendered page carries these, and only for the caller's own work.
const written = [
detail?.submittedText
? joinSections([heading(4, 'What you wrote'), detail.submittedText])
: undefined,
detail?.gradeComment
? joinSections([heading(4, "Teacher's feedback"), detail.gradeComment])
: undefined,
].filter(Boolean) as string[];
return joinSections([
heading(3, 'Your submission'),
parts.join('\n\n'),
'Read any of these with download_file. The API exposes no submitted text or written ' +
'feedback — only files, the graded flag and a numeric grade — so a comment-only ' +
'response from the teacher will not appear here.',
...written,
'Read any attachment with download_file.' +
(written.length === 0 && anyGraded(relevant)
? ' No written feedback was found for this submission. It is read from the web page rather ' +
'than an API, so treat this as "not found", not as "none was given" — the teacher may ' +
'have responded on paper or in person.'
: ''),
]);
}