Public Access
feat: integrate AI grading functionality
- 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:
1 parent
f40f4508fd
commit
993265318b
13 files changed
+927
-31
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user