feat: integrate AI grading functionality
Test / backend (push) Successful in 16s
Test / frontend (push) Successful in 24s
Release Pipeline / build-and-release (push) Successful in 5m22s

- Added OpenAI package to backend dependencies for AI grading.
- Implemented new grading routes and services to handle AI grading requests.
- Updated admin settings to include AI provider configuration options.
- Enhanced frontend components to support AI grading, including UI for testing AI connection and displaying AI feedback.
- Updated results component to allow for AI-assisted grading and display of suggested grades.
- Added internationalization support for new AI grading features in both English and Dutch.
This commit is contained in:
LaurensBartelds committed 2026-05-02 12:41:53 +02:00
1 parent f40f4508fd
commit 993265318b
13 files changed
+927 -31

No files matched your search

+4 -1
View File
@@ -33,7 +33,10 @@
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" diff .gitea/workflows/deploy.yml)",
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" diff HEAD -- .gitea/workflows/deploy.yml)",
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline -3 -- .gitea/workflows/deploy.yml)",
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline origin/main..HEAD)"
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline origin/main..HEAD)",
"Bash(grep -E \"\\\\.tsx$\")",
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\Laurens Bartelds\\\\Documents\\\\Gitea\\\\brainwave\" -Force)",
"Bash(Select-Object Name)"
]
}
}
+282 -2
View File
@@ -17,6 +17,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^7.0.3",
"openai": "^4.95.0",
"zod": "^3.24.2"
},
"devDependencies": {
@@ -955,12 +956,21 @@
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
}
},
"node_modules/@types/node-fetch": {
"version": "2.6.13",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.4"
}
},
"node_modules/@types/nodemailer": {
"version": "6.4.23",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.23.tgz",
@@ -1119,6 +1129,18 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -1132,6 +1154,18 @@
"node": ">= 0.6"
}
},
"node_modules/agentkeepalive": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
"license": "MIT",
"dependencies": {
"humanize-ms": "^1.2.1"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1142,6 +1176,12 @@
"node": ">=12"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -1315,6 +1355,18 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -1420,6 +1472,15 @@
"node": ">=4.0.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1534,6 +1595,21 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
@@ -1601,6 +1677,15 @@
"node": ">= 0.6"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -1723,6 +1808,62 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data-encoder": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
"license": "MIT"
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/formdata-node": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
"integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
"license": "MIT",
"dependencies": {
"node-domexception": "1.0.0",
"web-streams-polyfill": "4.0.0-beta.3"
},
"engines": {
"node": ">= 12.20"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1851,6 +1992,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
@@ -1892,6 +2048,15 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/humanize-ms": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -2454,6 +2619,46 @@
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
@@ -2527,6 +2732,51 @@
"wrappy": "1"
}
},
"node_modules/openai": {
"version": "4.104.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz",
"integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==",
"license": "Apache-2.0",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
},
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/openai/node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/openai/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -3125,6 +3375,12 @@
"node": ">=0.6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -3197,7 +3453,6 @@
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true,
"license": "MIT"
},
"node_modules/unpipe": {
@@ -3392,6 +3647,31 @@
}
}
},
"node_modules/web-streams-polyfill": {
"version": "4.0.0-beta.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
"integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+1
View File
@@ -19,6 +19,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^7.0.3",
"openai": "^4.95.0",
"zod": "^3.24.2"
},
"devDependencies": {
+2
View File
@@ -9,6 +9,7 @@ import examRoutes from './routes/exams.js'
import resultRoutes from './routes/results.js'
import adminRoutes from './routes/admin.js'
import setupRoutes from './routes/setup.js'
import gradeRoutes from './routes/grade.js'
import { errorHandler } from './middleware/error.js'
import { config } from './config.js'
import path from 'path'
@@ -60,6 +61,7 @@ app.use('/api/setup', setupRoutes)
app.use('/api/auth', authRoutes)
app.use('/api/exams', examRoutes)
app.use('/api/results', resultRoutes)
app.use('/api/grade', gradeRoutes)
app.use('/api/admin', adminRoutes)
// Health check with subsystem status
+29
View File
@@ -0,0 +1,29 @@
import { Router } from 'express'
import { z } from 'zod'
import { validateBody } from '../middleware/validate.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import { requireAuth } from '../middleware/auth.js'
import { gradeQuestionWithAI } from '../services/aiGrading.js'
const gradeRequestSchema = z.object({
type: z.enum(['essay', 'short-answer', 'translation', 'math', 'ordering', 'match']),
question: z.record(z.unknown()),
answer: z.unknown(),
language: z.string().min(1),
})
const router = Router()
router.use(requireAuth)
router.post(
'/',
validateBody(gradeRequestSchema),
asyncHandler(async (req, res) => {
const { type, question, answer, language } = req.body as z.infer<typeof gradeRequestSchema>
const result = await gradeQuestionWithAI({ type, question, answer, language })
res.json(result)
}),
)
export default router
+183
View File
@@ -0,0 +1,183 @@
import OpenAI from 'openai'
import { getSetting } from '../repositories/settingsRepo.js'
export interface GradingResult {
suggestedGrade: number
feedback: string
detail?: unknown
}
interface GradeRequest {
type: 'essay' | 'short-answer' | 'translation' | 'math' | 'ordering' | 'match'
question: Record<string, unknown>
answer: unknown
language: string
}
function getClient(): OpenAI | null {
const provider = getSetting('ai_provider')
const apiKey = getSetting('ai_api_key')
if (!provider || !apiKey) return null
const customBase = getSetting('ai_base_url')
const baseURL = provider === 'openai' ? undefined : (customBase || undefined)
return new OpenAI({ apiKey, baseURL })
}
function buildPrompt(req: GradeRequest): string {
const { type, question, answer, language } = req
const base = `You are an exam grading assistant. Grade the student's answer and respond ONLY with a JSON object containing:
- "suggestedGrade": number between 0.0 and 1.0
- "feedback": string with 1-3 sentences explaining the grade in ${language}
- "detail": type-specific breakdown object (see below)
Do not include any text outside the JSON object.`
switch (type) {
case 'essay': {
const rubric = (question.rubric as string[] | undefined) ?? []
return `${base}
Question: ${question.prompt}
Rubric criteria: ${rubric.join('; ') || 'None provided'}
Student answer: ${String(answer ?? '')}
Evaluate how well the essay covers the rubric criteria, structure, and quality. Provide "detail" as:
{ "rubricBreakdown": [{ "item": string, "met": boolean }] }
Each item should correspond to a rubric criterion.`
}
case 'short-answer': {
const accepted = [question.answer as string, ...(question.acceptedForms as string[] | undefined ?? [])].filter(Boolean)
return `${base}
Question: ${question.prompt}
Accepted answers: ${accepted.join(' | ')}
Student answer: ${String(answer ?? '')}
Determine if the student's answer is semantically equivalent to any accepted form. Provide "detail" as:
{ "matchedForm": string | null, "reason": string }`
}
case 'translation': {
const accepted = [question.answer as string, ...(question.acceptedForms as string[] | undefined ?? [])].filter(Boolean)
const source = question.sourceLanguage as string
const target = question.targetLanguage as string
return `${base}
Translate from ${source} to ${target}.
Question: ${question.prompt}
Accepted translations: ${accepted.join(' | ')}
Student answer: ${String(answer ?? '')}
Evaluate meaning accuracy and fluency, not literal word-for-word matching. Provide "detail" as:
{ "accuracy": number (0-1), "fluency": number (0-1), "notes": string }`
}
case 'math': {
const accepted = [question.answer as string, ...(question.acceptedForms as string[] | undefined ?? [])].filter(Boolean)
return `${base}
Question: ${question.prompt}
Accepted answers (LaTeX): ${accepted.join(' | ')}
Student answer (LaTeX): ${String(answer ?? '')}
Determine if the student's LaTeX expression is mathematically equivalent to the accepted answer. Provide "detail" as:
{ "equivalent": boolean, "explanation": string }`
}
case 'ordering': {
const correctOrder = question.answer as string[]
const items = question.items as Array<{ id: string; text: string }> | undefined
const itemMap = new Map(items?.map(i => [i.id, i.text]) ?? [])
const given = answer as string[]
const correctTexts = correctOrder.map(id => itemMap.get(id) ?? id)
const givenTexts = given?.map(id => itemMap.get(id) ?? id) ?? []
return `${base}
Question: ${question.prompt}
Correct order: ${correctTexts.join(' → ')}
Student order: ${givenTexts.join(' → ')}
Determine how many items are in the correct position. Provide "detail" as:
{ "correctPositions": number[], "totalItems": number }
correctPositions should be 1-based indices of items in the correct position.`
}
case 'match': {
const correctPairs = question.answer as Record<string, string>
const left = question.left as Array<{ id: string; text: string }> | undefined
const right = question.right as Array<{ id: string; text: string }> | undefined
const leftMap = new Map(left?.map(i => [i.id, i.text]) ?? [])
const rightMap = new Map(right?.map(i => [i.id, i.text]) ?? [])
const given = (answer as Record<string, string> | undefined) ?? {}
const correctPairTexts: string[] = []
const incorrectPairTexts: string[] = []
for (const [lId, rId] of Object.entries(correctPairs)) {
const pairText = `${leftMap.get(lId) ?? lId} → ${rightMap.get(rId) ?? rId}`
if (given[lId] === rId) {
correctPairTexts.push(pairText)
} else {
const actualR = given[lId] ? (rightMap.get(given[lId]) ?? given[lId]) : '(unanswered)'
incorrectPairTexts.push(`${leftMap.get(lId) ?? lId} → ${actualR} (should be ${rightMap.get(rId) ?? rId})`)
}
}
return `${base}
Question: ${question.prompt}
Correct pairings:
${Object.entries(correctPairs).map(([l, r]) => `${leftMap.get(l) ?? l} → ${rightMap.get(r) ?? r}`).join('\n')}
Student pairings:
${Object.entries(given).map(([l, r]) => `${leftMap.get(l) ?? l} → ${rightMap.get(r) ?? r}`).join('\n')}
Determine partial credit based on correct pairings. Provide "detail" as:
{ "correctPairs": string[], "incorrectPairs": string[] }
Use the human-readable text labels in the arrays.`
}
default:
return base
}
}
export async function gradeQuestionWithAI(req: GradeRequest): Promise<GradingResult> {
const client = getClient()
if (!client) {
throw new Error('AI grading is not configured. Please set provider and API key in admin settings.')
}
const model = getSetting('ai_model') || 'gpt-4o-mini'
const prompt = buildPrompt(req)
const completion = await client.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
temperature: 0.2,
max_completion_tokens: 800,
})
const raw = completion.choices[0]?.message?.content
if (!raw) {
throw new Error('Empty response from AI provider')
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
throw new Error('Invalid JSON response from AI provider')
}
const obj = parsed as Record<string, unknown>
const suggestedGrade = typeof obj.suggestedGrade === 'number'
? Math.max(0, Math.min(1, obj.suggestedGrade))
: 0
return {
suggestedGrade,
feedback: typeof obj.feedback === 'string' ? obj.feedback : '',
detail: obj.detail,
}
}
+4
View File
@@ -21,6 +21,10 @@ const ALLOWED_SETTING_KEYS = [
'smtp_secure',
'app_url',
'max_upload_mb',
'ai_provider',
'ai_model',
'ai_api_key',
'ai_base_url',
] as const
export const updateSettingSchema = z.object({
+17
View File
@@ -1,4 +1,5 @@
import { apiFetch } from '../utils/api.js'
import type { GradingResult } from './gradeApi.js'
export interface AdminUser {
id: number
@@ -42,6 +43,10 @@ export interface SiteSettings {
smtp_from: string
smtp_secure: string
app_url: string
ai_provider: string
ai_model: string
ai_api_key: string
ai_base_url: string
}
export function getStats(): Promise<DashboardStats> {
@@ -90,3 +95,15 @@ export function sendTestEmail(to: string | undefined, smtp: SmtpConfig): Promise
body: JSON.stringify({ to, smtp }),
})
}
export function testAiConnection(): Promise<GradingResult> {
return apiFetch('/api/grade', {
method: 'POST',
body: JSON.stringify({
type: 'short-answer',
question: { prompt: 'What is 2+2?', answer: '4' },
answer: '4',
language: 'en',
}),
})
}
+21
View File
@@ -0,0 +1,21 @@
import { apiFetch } from '../utils/api.js'
export interface GradingResult {
suggestedGrade: number
feedback: string
detail?: unknown
}
export interface GradeRequest {
type: 'essay' | 'short-answer' | 'translation' | 'math' | 'ordering' | 'match'
question: Record<string, unknown>
answer: unknown
language: string
}
export function gradeQuestion(req: GradeRequest): Promise<GradingResult> {
return apiFetch('/api/grade', {
method: 'POST',
body: JSON.stringify(req),
})
}
+106
View File
@@ -28,6 +28,11 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
text: string;
} | null>(null);
const [testEmailSending, setTestEmailSending] = useState(false);
const [testAiMsg, setTestAiMsg] = useState<{
ok: boolean;
text: string;
} | null>(null);
const [testAiSending, setTestAiSending] = useState(false);
const loadStats = useCallback(async () => {
try {
@@ -125,6 +130,21 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
}
};
const sendTestAi = async () => {
if (!settingsDraft) return;
setTestAiSending(true);
setTestAiMsg(null);
try {
await adminApi.testAiConnection();
setTestAiMsg({ ok: true, text: t("admin.aiGrading.testSuccess") });
} catch (e) {
setTestAiMsg({ ok: false, text: (e as Error).message });
} finally {
setTestAiSending(false);
setTimeout(() => setTestAiMsg(null), 6000);
}
};
const handleUserSearch = (e: React.FormEvent) => {
e.preventDefault();
void loadUsers(userSearch);
@@ -591,6 +611,92 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div>
</div>
<div className="admin-panel">
<div className="admin-panel-header">
<h3>{t("admin.aiGrading.title")}</h3>
</div>
<div className="admin-panel-body admin-settings-form">
<div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-ai-provider">
<span className="admin-setting-title">{t("admin.aiGrading.provider")}</span>
</label>
<select
id="setting-ai-provider"
className="input admin-setting-input"
value={settingsDraft.ai_provider || ''}
onChange={(e) => updateDraft("ai_provider", e.target.value)}
>
<option value="">—</option>
<option value="openai">OpenAI</option>
<option value="openai-compatible">OpenAI-compatible</option>
</select>
</div>
{settingsDraft.ai_provider === 'openai-compatible' && (
<div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-ai-base-url">
<span className="admin-setting-title">{t("admin.aiGrading.baseUrl")}</span>
<span className="admin-setting-desc">
{t("admin.aiGrading.baseUrlDesc")}
</span>
</label>
<input
id="setting-ai-base-url"
className="input admin-setting-input"
placeholder="https://api.example.com/v1"
value={settingsDraft.ai_base_url || ''}
onChange={(e) => updateDraft("ai_base_url", e.target.value)}
/>
</div>
)}
<div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-ai-model">
<span className="admin-setting-title">{t("admin.aiGrading.model")}</span>
</label>
<input
id="setting-ai-model"
className="input admin-setting-input"
placeholder="gpt-4o-mini"
value={settingsDraft.ai_model || ''}
onChange={(e) => updateDraft("ai_model", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-ai-key">
<span className="admin-setting-title">{t("admin.aiGrading.apiKey")}</span>
</label>
<input
id="setting-ai-key"
className="input admin-setting-input"
type="password"
autoComplete="off"
value={settingsDraft.ai_api_key || ''}
onChange={(e) => updateDraft("ai_api_key", e.target.value)}
/>
</div>
<div className="admin-setting-row admin-setting-row-stacked">
<div className="admin-setting-label">
<span className="admin-setting-title">{t("admin.aiGrading.testButton")}</span>
</div>
<div className="admin-test-email-row">
<button
className="btn btn-sm"
onClick={() => void sendTestAi()}
disabled={testAiSending || !settingsDraft?.ai_provider || !settingsDraft?.ai_api_key || (settingsDraft?.ai_provider === 'openai-compatible' && !settingsDraft?.ai_base_url)}
>
{testAiSending ? t("admin.sending") : t("admin.aiGrading.testButton")}
</button>
</div>
{testAiMsg && (
<div
className={`admin-alert ${testAiMsg.ok ? "admin-alert-success" : "admin-alert-error"} admin-alert-inline`}
>
{testAiMsg.text}
</div>
)}
</div>
</div>
</div>
<div className="admin-panel">
<div className="admin-panel-header">
<h3>{t("admin.maintenance")}</h3>
+238 -28
View File
@@ -1,11 +1,12 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Exam, ExamState } from '../types'
import type { Exam, ExamState, Question } from '../types'
import { gradeQuestion } from '../utils/grading'
import { formatTime } from '../utils/formatting'
import Tex from './Tex'
import QuestionMedia from './QuestionMedia'
import QuestionRenderer from './questions/QuestionRenderer'
import * as gradeApi from '../api/gradeApi'
interface Props {
exam: Exam
@@ -16,38 +17,109 @@ interface Props {
reviewOnly?: boolean
}
const AI_TYPES = new Set(['essay', 'short-answer', 'translation', 'math-input', 'match', 'order'])
function isAiApplicable(q: Question): boolean {
return AI_TYPES.has(q.type)
}
function mapTypeForApi(q: Question): gradeApi.GradeRequest['type'] | null {
switch (q.type) {
case 'essay': return 'essay'
case 'short-answer': return 'short-answer'
case 'translation': return 'translation'
case 'math-input': return 'math'
case 'match': return 'match'
case 'order': return 'ordering'
default: return null
}
}
export default function Results({ exam, state, onRestart, onExit, onRedoFailed, reviewOnly }: Props) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const { answers, startedAt, completedAt } = state
const elapsedMs = (completedAt ?? Date.now()) - startedAt
const graded = exam.questions.map((q) => ({
q,
userAnswer: answers[q.id],
result: gradeQuestion(q, answers[q.id]),
}))
const [aiReviews, setAiReviews] = useState<Record<string, gradeApi.GradingResult>>({})
const [aiLoading, setAiLoading] = useState<Record<string, boolean>>({})
const [aiErrors, setAiErrors] = useState<Record<string, string>>({})
const [acceptedGrades, setAcceptedGrades] = useState<Record<string, number>>({})
const graded = exam.questions.map((q) => {
const accepted = acceptedGrades[q.id]
const base = gradeQuestion(q, answers[q.id])
if (accepted != null) {
const pts = q.points ?? 1
return {
...base,
status: (accepted >= 0.5 ? 'correct' : 'wrong') as typeof base.status,
correct: accepted >= 0.5,
points: Math.round(pts * accepted),
}
}
return base
})
const totalPoints = exam.questions.reduce((a, q) => a + (q.points ?? 1), 0)
const scorePoints = graded.reduce((a, g) => a + g.result.points, 0)
const gradedCount = graded.filter((g) => g.result.status !== 'ungraded').length
const correctCount = graded.filter((g) => g.result.correct).length
const scorePoints = graded.reduce((a, g) => a + g.points, 0)
const gradedCount = graded.filter((g) => g.status !== 'ungraded').length
const correctCount = graded.filter((g) => g.correct).length
const failedCount = gradedCount - correctCount
const pct = totalPoints ? Math.round((scorePoints / totalPoints) * 100) : 0
const passing = exam.passingScore ?? 60
const passed = pct >= passing
const byCat: Record<string, { earned: number; possible: number; correct: number; total: number }> = {}
graded.forEach((g) => {
const cat = g.q.category ?? 'Uncategorized'
graded.forEach((g, i) => {
const cat = exam.questions[i].category ?? 'Uncategorized'
if (!byCat[cat]) byCat[cat] = { earned: 0, possible: 0, correct: 0, total: 0 }
byCat[cat].possible += g.q.points ?? 1
byCat[cat].earned += g.result.points
byCat[cat].possible += exam.questions[i].points ?? 1
byCat[cat].earned += g.points
byCat[cat].total += 1
if (g.result.correct) byCat[cat].correct += 1
if (g.correct) byCat[cat].correct += 1
})
const [openIdx, setOpenIdx] = useState<number | null>(null)
const runAiReview = async (q: Question) => {
const type = mapTypeForApi(q)
if (!type) return
setAiLoading((prev) => ({ ...prev, [q.id]: true }))
setAiErrors((prev) => { const n = { ...prev }; delete n[q.id]; return n })
try {
const result = await gradeApi.gradeQuestion({
type,
question: q as unknown as Record<string, unknown>,
answer: answers[q.id],
language: i18n.language === 'nl' ? 'nl' : 'en',
})
setAiReviews((prev) => ({ ...prev, [q.id]: result }))
} catch (e) {
setAiErrors((prev) => ({ ...prev, [q.id]: (e as Error).message }))
} finally {
setAiLoading((prev) => { const n = { ...prev }; delete n[q.id]; return n })
}
}
const acceptGrade = (qid: string, grade: number) => {
setAcceptedGrades((prev) => ({ ...prev, [qid]: grade }))
}
const keepOriginal = (qid: string) => {
setAcceptedGrades((prev) => { const n = { ...prev }; delete n[qid]; return n })
setAiReviews((prev) => { const n = { ...prev }; delete n[qid]; return n })
}
const statusLabel = (status: string) => {
switch (status) {
case 'correct': return t('exam.correct')
case 'wrong': return t('exam.notQuite')
case 'ungraded': return 'Ungraded'
case 'unanswered': return 'Unanswered'
default: return status
}
}
return (
<div className="results">
<div className="results-eyebrow">
@@ -96,39 +168,98 @@ export default function Results({ exam, state, onRestart, onExit, onRedoFailed,
)}
<h2 className="review-heading">{t('results.review')}</h2>
{graded.map((g, i) => {
const promptText = g.q.prompt
{exam.questions.map((q, i) => {
const g = graded[i]
const promptText = q.prompt
.replace(/\$\$([^$]+)\$\$/g, '[math]')
.replace(/\$([^$]+)\$/g, '[math]')
const aiResult = aiReviews[q.id]
const aiBusy = aiLoading[q.id]
const aiErr = aiErrors[q.id]
const accepted = acceptedGrades[q.id]
const applicable = isAiApplicable(q)
return (
<div key={g.q.id} className={`review-item${openIdx === i ? ' open' : ''}`}>
<div key={q.id} className={`review-item${openIdx === i ? ' open' : ''}`}>
<div className="review-item-header" onClick={() => setOpenIdx(openIdx === i ? null : i)}>
<span className="review-num">{String(i + 1).padStart(2, '0')}</span>
<span className="review-title">
<Tex>{promptText.slice(0, 90)}</Tex>
{promptText.length > 90 ? '…' : ''}
</span>
<span className={`review-status ${g.result.status}`}>{g.result.status}</span>
<span className={`review-status ${g.status}`}>
{statusLabel(g.status)}
{accepted != null && ' ✦'}
</span>
<span style={{ color: 'var(--ink-4)' }}>{openIdx === i ? '−' : '+'}</span>
</div>
{openIdx === i && (
<div className="review-body">
<div className="q-prompt" style={{ fontSize: 20, marginBottom: 20 }}>
<Tex>{g.q.prompt}</Tex>
<Tex>{q.prompt}</Tex>
</div>
<QuestionMedia q={g.q} />
<QuestionRenderer key={g.q.id} q={g.q} value={g.userAnswer} onChange={() => {}} locked graded />
{g.q.explanation && (
<div className="feedback" style={{ marginTop: 24 }}>
<div className="feedback-label">{t('results.explanation')}</div>
<div className="feedback-body"><Tex>{g.q.explanation}</Tex></div>
<QuestionMedia q={q} />
<QuestionRenderer key={q.id} q={q} value={answers[q.id]} onChange={() => {}} locked graded />
{applicable && !reviewOnly && (
<div style={{ marginTop: 20 }}>
{!aiResult && !aiBusy && (
<button
className="btn btn-sm"
onClick={(e) => { e.stopPropagation(); void runAiReview(q) }}
>
{t('results.ai.button')} ▶
</button>
)}
{aiBusy && (
<span className="muted" style={{ fontSize: 13 }}>{t('common.loading')}</span>
)}
{aiErr && (
<div style={{ marginTop: 8, padding: '8px 12px', borderRadius: 6, background: 'var(--wrong-weak)', color: 'var(--wrong)', fontSize: 13 }}>
{t('results.ai.error')}: {aiErr}
</div>
)}
{aiResult && (
<div style={{ marginTop: 12, padding: 16, border: '1px solid var(--line-2)', borderRadius: 10, background: 'var(--paper-2)' }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 4 }}>
{t('results.ai.suggestedGrade')}: {Math.round(aiResult.suggestedGrade * 100)}%
</div>
<div style={{ fontSize: 13, color: 'var(--ink-2)', lineHeight: 1.6, marginBottom: 12 }}>
{aiResult.feedback}
</div>
{!!aiResult.detail && (
<AiDetail detail={aiResult.detail} type={q.type} />
)}
<div style={{ display: 'flex', gap: 10, marginTop: 12 }}>
<button
className="btn btn-primary btn-sm"
onClick={(e) => { e.stopPropagation(); acceptGrade(q.id, aiResult.suggestedGrade) }}
>
{t('results.ai.accept')} ✓
</button>
<button
className="btn btn-ghost btn-sm"
onClick={(e) => { e.stopPropagation(); keepOriginal(q.id) }}
>
{t('results.ai.keepOriginal')} ✗
</button>
</div>
</div>
)}
</div>
)}
{g.q.rubric && (
{q.explanation && (
<div className="feedback" style={{ marginTop: 24 }}>
<div className="feedback-label">{t('results.explanation')}</div>
<div className="feedback-body"><Tex>{q.explanation}</Tex></div>
</div>
)}
{q.rubric && (
<div style={{ marginTop: 20, fontSize: 13.5 }}>
<div className="meta-label" style={{ marginBottom: 8 }}>{t('results.rubric')}</div>
<ul style={{ paddingLeft: 18, margin: 0, lineHeight: 1.7, color: 'var(--ink-2)' }}>
{g.q.rubric.map((r, j) => <li key={j}><Tex>{r}</Tex></li>)}
{q.rubric.map((r, j) => <li key={j}><Tex>{r}</Tex></li>)}
</ul>
</div>
)}
@@ -154,3 +285,82 @@ export default function Results({ exam, state, onRestart, onExit, onRedoFailed,
</div>
)
}
function AiDetail({ detail, type }: { detail: unknown; type: string }) {
const { t } = useTranslation()
if (!detail || typeof detail !== 'object') return null
const d = detail as Record<string, unknown>
if (type === 'essay' && Array.isArray(d.rubricBreakdown)) {
return (
<div style={{ marginTop: 8 }}>
<div className="meta-label" style={{ marginBottom: 6, fontSize: 12 }}>{t('results.ai.rubricBreakdown')}</div>
<table style={{ fontSize: 12, width: '100%', borderCollapse: 'collapse' }}>
<tbody>
{(d.rubricBreakdown as Array<{ item: string; met: boolean }>).map((r, i) => (
<tr key={i} style={{ borderBottom: '1px solid var(--line-1)' }}>
<td style={{ padding: '4px 0' }}>{r.item}</td>
<td style={{ textAlign: 'right', color: r.met ? 'var(--right)' : 'var(--wrong)' }}>
{r.met ? '✓' : '✗'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
if ((type === 'ordering' || type === 'order') && Array.isArray(d.correctPositions)) {
return (
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ink-2)' }}>
{t('results.ai.suggestedGrade')}: {(d.correctPositions as number[]).length} / {Number(d.totalItems ?? 0)} {t('results.correct').toLowerCase()}
</div>
)
}
if (type === 'match' && (Array.isArray(d.correctPairs) || Array.isArray(d.incorrectPairs))) {
return (
<div style={{ marginTop: 8, fontSize: 12 }}>
{Array.isArray(d.correctPairs) && (d.correctPairs as string[]).length > 0 && (
<div style={{ color: 'var(--right)', marginBottom: 4 }}>
✓ {(d.correctPairs as string[]).join(', ')}
</div>
)}
{Array.isArray(d.incorrectPairs) && (d.incorrectPairs as string[]).length > 0 && (
<div style={{ color: 'var(--wrong)' }}>
✗ {(d.incorrectPairs as string[]).join(', ')}
</div>
)}
</div>
)
}
if (type === 'math-input' && typeof d.equivalent === 'boolean') {
return (
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ink-2)' }}>
{d.explanation as string}
</div>
)
}
if (type === 'translation' && (typeof d.accuracy === 'number' || typeof d.fluency === 'number')) {
return (
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ink-2)' }}>
Accuracy: {Math.round((d.accuracy as number) * 100)}% · Fluency: {Math.round((d.fluency as number) * 100)}%
{!!d.notes && <div style={{ marginTop: 4 }}>{d.notes as string}</div>}
</div>
)
}
if (type === 'short-answer' && d.matchedForm != null) {
return (
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ink-2)' }}>
{d.reason as string}
</div>
)
}
return null
}
+20
View File
@@ -111,6 +111,15 @@
"untimed": "Untimed"
},
"results": {
"ai": {
"button": "AI Review",
"suggestedGrade": "AI Suggested Grade",
"feedback": "AI Feedback",
"accept": "Accept",
"keepOriginal": "Keep original",
"rubricBreakdown": "Rubric breakdown",
"error": "AI grading failed — please grade manually"
},
"reviewing": "Reviewing past attempt",
"examComplete": "Exam complete",
"scored": "You scored ",
@@ -205,6 +214,17 @@
"storageWarningCritical": "You are using over 95% of your storage. Delete some items to free up space."
},
"admin": {
"aiGrading": {
"title": "AI Grading",
"provider": "Provider",
"model": "Model",
"apiKey": "API Key",
"baseUrl": "Base URL",
"baseUrlDesc": "Custom API base URL for OpenAI-compatible endpoints",
"testButton": "Test connection",
"testSuccess": "Connection successful",
"testFailed": "Connection failed"
},
"administration": "Administration",
"dashboard": "Dashboard",
"backToSite": "← Back to site",
+20
View File
@@ -111,6 +111,15 @@
"untimed": "Ongetimed"
},
"results": {
"ai": {
"button": "AI-nakijken",
"suggestedGrade": "AI-suggestie",
"feedback": "AI-feedback",
"accept": "Accepteren",
"keepOriginal": "Origineel houden",
"rubricBreakdown": "Rubric-uitsplitsing",
"error": "AI-beoordeling mislukt — beoordeel handmatig"
},
"reviewing": "Eerdere poging bekijken",
"examComplete": "Examen voltooid",
"scored": "Je score: ",
@@ -205,6 +214,17 @@
"storageWarningCritical": "Je gebruikt meer dan 95% van je opslag. Verwijder enkele items om ruimte vrij te maken."
},
"admin": {
"aiGrading": {
"title": "AI-beoordeling",
"provider": "Provider",
"model": "Model",
"apiKey": "API-sleutel",
"baseUrl": "Basis-URL",
"baseUrlDesc": "Aangepaste API basis-URL voor OpenAI-compatible endpoints",
"testButton": "Verbinding testen",
"testSuccess": "Verbinding geslaagd",
"testFailed": "Verbinding mislukt"
},
"administration": "Beheer",
"dashboard": "Dashboard",
"backToSite": "← Terug naar site",