diff --git a/CLAUDE.md b/CLAUDE.md index bc004c8..a0a7570 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,6 +127,11 @@ These cost real time to discover; `docs/API.md` has the full list with evidence. no fetch-by-id, and the payload has no submitted text, grade comment or graded-at — `/api/v1`, which had them, is not served here. Don't imply absent feedback means none was given. +- **A grade is a percentage (`Number` 0-100) or absent; there is no text grade.** + Teachers commonly grade with `gradeComment` alone, so "graded by feedback" is + a complete answer. `formatGradeState` in `mcp/tools/submissions.ts` owns that + wording — don't reintroduce "no numeric grade recorded", which reads as a + fault. - **Submitted text and grade comments are scraped, not fetched.** No API exposes them; the legacy page `GET /homework/{taskId}` renders them, and it authenticates by `jwt` **cookie**, not bearer. `core/homework-page.ts` parses diff --git a/docs/API.md b/docs/API.md index ac92fd9..8051b8c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -140,6 +140,13 @@ attributes the project's own e2e tests use. Note the page authenticates with the identity provider. Measured: 4 of 7 graded submissions in one course carried feedback no API call can return. +**A grade is a percentage or nothing.** The submission schema is +`grade: { type: Number, min: 0, max: 100 }` and `gradeComment: { type: String }` +— there is no textual grade field. In practice teachers frequently grade with +`gradeComment` alone and leave `grade` unset, so a written "OK" is the whole +verdict for that submission. Treat a missing `grade` as "no percentage given", +never as "ungraded" (`graded` is its own boolean) and never as an error. + **files-storage ignores `parentType` when listing.** Asking for `.../gradings/{submissionId}` returns the files parented to that id whatever their type — the records come back saying `parentType: "submissions"`. The path diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 9fcdacd..b547daa 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -26,7 +26,9 @@ How the content is organised, and the usual path through it: - **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 that task's submission: the files, - the graded flag, the grade, what the user wrote, and the teacher's written feedback. list_submissions + the graded flag, the grade, what the user wrote, and the teacher's written feedback. A grade is a + percentage (0-100) or absent — there is no textual grade — and teachers often grade with the written + feedback alone, so "graded by feedback" is a complete result, not missing data. 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 diff --git a/src/mcp/tools/submissions.ts b/src/mcp/tools/submissions.ts index e9ee30e..611fef8 100644 --- a/src/mcp/tools/submissions.ts +++ b/src/mcp/tools/submissions.ts @@ -98,12 +98,41 @@ function anyGraded(statuses: SubmissionStatus[]): boolean { return statuses.some((status) => status.isGraded); } +/** + * How to describe the grading. + * + * The data model has no textual grade: `grade` is `Number, min 0, max 100` — a + * percentage — and `gradeComment` is free text. Teachers routinely grade with + * the comment alone, leaving `grade` unset, so a written "OK" *is* the verdict + * for that submission rather than a missing value. Saying "no numeric grade + * recorded" in that case reads as broken data, which is why it is only said + * when there is genuinely nothing to show. + */ +export function formatGradeState( + status: Pick, + hasWrittenFeedback: boolean, + percentFromPage?: number, +): string { + if (!status.isGraded) return 'not graded yet'; + + // `grade` is a percentage in the schema, so render it as one rather than as + // a bare number that could be mistaken for a mark out of 6 or 15. + const percent = status.grade ?? percentFromPage; + if (percent !== null && percent !== undefined) { + return hasWrittenFeedback ? `graded **${percent}%**, with feedback` : `graded **${percent}%**`; + } + if (hasWrittenFeedback) return 'graded by feedback, with no percentage given'; + return 'marked graded, but neither a percentage nor feedback was found'; +} + function formatRow({ task, status }: { task: TaskContent; status: SubmissionStatus }): string { const state = status.isSubmitted ? 'submitted' : 'not submitted'; + // The list does not fetch pages, so it cannot know whether feedback exists; + // it says only what the API told it. const graded = status.isGraded ? status.grade !== null && status.grade !== undefined - ? `graded ${status.grade}` - : 'graded (no numeric grade)' + ? `graded ${status.grade}%` + : 'graded (percentage not set — check get_task for written feedback)' : 'not graded'; const group = status.submittingCourseGroupName ? ` — group "${status.submittingCourseGroupName}"` : ''; const course = task.courseName ? ` [${task.courseName}]` : ''; @@ -166,14 +195,7 @@ 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}**` - : percent !== undefined - ? `graded **${percent}%**` - : 'graded (no numeric grade recorded)' - : 'not graded yet'; + const graded = formatGradeState(status, Boolean(detail?.gradeComment), detail?.gradePercent); parts.push( [ diff --git a/test/submissions.test.ts b/test/submissions.test.ts new file mode 100644 index 0000000..e22632e --- /dev/null +++ b/test/submissions.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { formatGradeState } from '../src/mcp/tools/submissions.ts'; + +/** + * The schema has no textual grade — `grade` is a 0-100 percentage and + * `gradeComment` is free text — but teachers commonly grade with the comment + * alone. These cases keep the wording honest about which of those happened. + */ +describe('formatGradeState', () => { + it('reports a percentage as a percentage, not a bare number', () => { + assert.equal(formatGradeState({ isGraded: true, grade: 85 }, false), 'graded **85%**'); + }); + + it('keeps 0% distinct from "no grade given"', () => { + assert.equal(formatGradeState({ isGraded: true, grade: 0 }, false), 'graded **0%**'); + }); + + it('treats feedback alone as the verdict, not as missing data', () => { + // The real case: teacher wrote "OK" and set no percentage. + assert.equal( + formatGradeState({ isGraded: true, grade: null }, true), + 'graded by feedback, with no percentage given', + ); + }); + + it('mentions both when a percentage and feedback are present', () => { + assert.equal(formatGradeState({ isGraded: true, grade: 70 }, true), 'graded **70%**, with feedback'); + }); + + it('falls back to the percentage read off the page when the API omits it', () => { + assert.equal(formatGradeState({ isGraded: true, grade: null }, false, 40), 'graded **40%**'); + }); + + it('says plainly when graded but nothing at all was found', () => { + assert.match( + formatGradeState({ isGraded: true, grade: null }, false), + /neither a percentage nor feedback was found/, + ); + }); + + it('does not claim a grade for an ungraded submission', () => { + assert.equal(formatGradeState({ isGraded: false, grade: null }, false), 'not graded yet'); + assert.equal(formatGradeState({ isGraded: false, grade: null }, true), 'not graded yet'); + }); +});