feat: integrate AI features for question generation and tutoring
Test / backend (push) Failing after 14s
Test / frontend (push) Failing after 19s

- Added AI routes for generating questions, providing hints, and tutor chat functionality.
- Implemented AI client setup and model retrieval in aiClient.ts.
- Created aiAssist.ts for handling AI-related operations including question generation and hint retrieval.
- Updated services to utilize the new AI client and assist functions.
- Enhanced frontend components to support AI question generation and tutor chat, including UI for generating questions and displaying hints.
- Added localization strings for new AI features in English and Dutch.
- Updated exam review process to include AI-generated hints and tutor chat functionality.
This commit is contained in:
LaurensBartelds committed 2026-05-02 17:22:56 +02:00
1 parent 1ec1633dd9
commit 63aa9b572a
16 files changed
+881 -36

No files matched your search

+135
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import * as examApi from '../api/examApi'
import * as aiApi from '../api/aiApi'
import type { Exam, Question, QuestionType, Choice } from '../types'
const QUESTION_TYPES: { value: QuestionType; labelKey: string }[] = [
@@ -59,6 +60,7 @@ export default function ExamEditor() {
const [saveMsg, setSaveMsg] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(isNew)
const [tags, setTags] = useState<string[]>([])
const [aiPanelOpen, setAiPanelOpen] = useState(false)
// Load existing exam
useEffect(() => {
@@ -92,6 +94,22 @@ export default function ExamEditor() {
setSelectedIdx(exam.questions.length)
}
const appendGeneratedQuestions = useCallback((generated: aiApi.GeneratedQuestion[]) => {
let firstNewIdx = -1
updateExam((draft) => {
firstNewIdx = draft.questions.length
for (const g of generated) {
const idx = draft.questions.length
const merged: Question = {
...(g as Omit<Question, 'id'>),
id: `q${idx + 1}`,
}
draft.questions.push(merged)
}
})
if (firstNewIdx >= 0) setSelectedIdx(firstNewIdx)
}, [updateExam])
const removeQuestion = (idx: number) => {
updateExam((draft) => {
draft.questions.splice(idx, 1)
@@ -202,6 +220,21 @@ export default function ExamEditor() {
<div className="exam-editor-add">
<div className="sidebar-section-label" style={{ marginTop: 24 }}>{t('editor.addQuestion')}</div>
<button
className="btn btn-sm"
style={{ width: '100%', marginBottom: 8 }}
onClick={() => setAiPanelOpen((v) => !v)}
>
{aiPanelOpen ? t('editor.aiClose') : t('editor.aiGenerate')}
</button>
{aiPanelOpen && (
<AIGeneratePanel
onGenerated={(qs) => {
appendGeneratedQuestions(qs)
setAiPanelOpen(false)
}}
/>
)}
<div className="exam-editor-type-grid">
{QUESTION_TYPES.map((tType) => (
<button
@@ -309,6 +342,108 @@ function TagsEditor({ tags, onChange }: { tags: string[]; onChange: (tags: strin
)
}
/* ─── AI Generate Panel ─── */
const AI_GENERATE_TYPES: { value: QuestionType; labelKey: string }[] = QUESTION_TYPES
function AIGeneratePanel({ onGenerated }: { onGenerated: (qs: aiApi.GeneratedQuestion[]) => void }) {
const { t, i18n } = useTranslation()
const [topic, setTopic] = useState('')
const [type, setType] = useState<QuestionType>('short-answer')
const [count, setCount] = useState(3)
const [context, setContext] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const submit = async () => {
if (!topic.trim()) {
setErr(t('editor.aiTopicRequired'))
return
}
setBusy(true)
setErr(null)
try {
const resp = await aiApi.generateQuestions({
topic: topic.trim(),
type,
count,
language: i18n.language || 'en',
context: context.trim() || undefined,
})
onGenerated(resp.questions)
setTopic('')
setContext('')
} catch (e) {
setErr((e as Error).message)
} finally {
setBusy(false)
}
}
return (
<div
style={{
padding: 12,
marginBottom: 12,
border: '1px solid var(--line-2)',
borderRadius: 6,
background: 'var(--paper)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>{t('editor.aiPanelTitle')}</div>
<input
className="input"
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder={t('editor.aiTopicPlaceholder')}
disabled={busy}
style={{ fontSize: 12 }}
/>
<textarea
className="input"
value={context}
onChange={(e) => setContext(e.target.value)}
placeholder={t('editor.aiContextPlaceholder')}
disabled={busy}
rows={3}
style={{ fontSize: 12, resize: 'vertical' }}
/>
<div style={{ display: 'flex', gap: 6 }}>
<select
className="input"
value={type}
onChange={(e) => setType(e.target.value as QuestionType)}
disabled={busy}
style={{ flex: 1, fontSize: 12 }}
>
{AI_GENERATE_TYPES.map((tt) => (
<option key={tt.value} value={tt.value}>
{t(tt.labelKey)}
</option>
))}
</select>
<input
className="input"
type="number"
min={1}
max={10}
value={count}
onChange={(e) => setCount(Math.max(1, Math.min(10, Number(e.target.value) || 1)))}
disabled={busy}
style={{ width: 60, fontSize: 12 }}
/>
</div>
{err && <div style={{ fontSize: 11, color: 'var(--wrong)' }}>{err}</div>}
<button className="btn btn-primary btn-sm" onClick={() => void submit()} disabled={busy}>
{busy ? t('editor.aiGenerating') : t('editor.aiGenerateNow')}
</button>
</div>
)
}
/* ─── Question Editor ─── */
interface QEProps {
+65 -1
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import type { Exam, ExamState, Question } from "../types";
import { gradeQuestion } from "../utils/grading";
import { formatTime } from "../utils/formatting";
import * as aiApi from "../api/aiApi";
import Tex from "./Tex";
import QuestionMedia from "./QuestionMedia";
import QuestionRenderer from "./questions/QuestionRenderer";
@@ -27,7 +28,7 @@ export default function ExamRunner({
onNavigate,
n,
}: Readonly<Props>) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const urlIdx = Math.max(0, (Number((n ?? "").replace(/^q/, "")) || 1) - 1);
const { answers, flags, startedAt, feedbackMode } = state;
const currentIdx = urlIdx;
@@ -40,6 +41,35 @@ export default function ExamRunner({
setRevealed(!!state.revealed?.[q.id]);
}, [currentIdx, q.id, state.revealed]);
const [hintCache, setHintCache] = useState<Record<string, string>>({});
const [hintLoading, setHintLoading] = useState(false);
const [hintError, setHintError] = useState<string | null>(null);
const currentHint = hintCache[q.id];
useEffect(() => {
setHintError(null);
}, [q.id]);
const requestHint = async () => {
if (currentHint || hintLoading) return;
setHintLoading(true);
setHintError(null);
try {
const safeQ: Record<string, unknown> = { ...(q as unknown as Record<string, unknown>) };
delete safeQ.answer;
delete safeQ.acceptedForms;
const res = await aiApi.getHint({
question: safeQ,
language: i18n.language || 'en',
});
setHintCache((prev) => ({ ...prev, [q.id]: res.hint }));
} catch (e) {
setHintError((e as Error).message);
} finally {
setHintLoading(false);
}
};
const setAnswer = (val: unknown) =>
setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } }));
@@ -225,6 +255,26 @@ export default function ExamRunner({
graded={graded}
/>
{(currentHint || hintError) && (
<div
style={{
marginTop: 12,
padding: 12,
borderLeft: '3px solid var(--accent)',
background: 'var(--paper)',
fontSize: 13,
color: 'var(--ink-2)',
borderRadius: 4,
}}
>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginBottom: 4 }}>
{t('exam.hintLabel')}
</div>
{currentHint && <Tex>{currentHint}</Tex>}
{hintError && <span style={{ color: 'var(--wrong)' }}>{hintError}</span>}
</div>
)}
{graded && result && q.type !== "essay" && (
<div className={`feedback ${result.correct ? "correct" : "wrong"}`}>
<div className="feedback-label">
@@ -259,6 +309,20 @@ export default function ExamRunner({
<button className="btn btn-ghost" onClick={toggleFlag}>
{flags[q.id] ? t('exam.flagged') : t('exam.flagForReview')}
</button>
{!revealed && (
<button
className="btn btn-ghost"
onClick={() => void requestHint()}
disabled={hintLoading || !!currentHint}
title={t('exam.hintTitle')}
>
{hintLoading
? t('exam.hintLoading')
: currentHint
? t('exam.hintShown')
: t('exam.needHint')}
</button>
)}
<span className="spacer" />
{feedbackMode === "immediate" &&
!revealed &&
+1
View File
@@ -85,6 +85,7 @@ export default function MyResults({ onLoadExam }: Props) {
...(state || {}),
completedAt: typeof completedAt === 'number' ? completedAt * 1000 : Date.now(),
}))
localStorage.setItem('brainwave.review.resultId', String(item.id))
navigate(`/review/${getExamUrlId(data.exam as unknown as { title: string; id?: string })}`)
} catch (e) {
setError((e as Error).message)
+2 -2
View File
@@ -166,7 +166,7 @@ export default function Overview({ examCount, resultCount, recentResults, onLoad
/>
</button>
<div
{/* <div
className="workspace-card"
style={{ flex: 1, minWidth: 220, padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}
>
@@ -189,7 +189,7 @@ export default function Overview({ examCount, resultCount, recentResults, onLoad
{importing ? t('workspace.importing') : t('workspace.import')}
</button>
</div>
</div>
</div> */}
<button
className="workspace-card"
+123 -1
View File
@@ -7,6 +7,7 @@ import Tex from './Tex'
import QuestionMedia from './QuestionMedia'
import QuestionRenderer from './questions/QuestionRenderer'
import * as gradeApi from '../api/gradeApi'
import * as aiApi from '../api/aiApi'
interface Props {
exam: Exam
@@ -15,6 +16,7 @@ interface Props {
onExit: () => void
onRedoFailed?: () => void
reviewOnly?: boolean
resultId?: number | null
}
const AI_TYPES = new Set(['essay', 'short-answer', 'translation', 'math-input', 'match', 'order'])
@@ -35,7 +37,7 @@ function mapTypeForApi(q: Question): gradeApi.GradeRequest['type'] | null {
}
}
export default function Results({ exam, state, onRestart, onExit, onRedoFailed, reviewOnly }: Props) {
export default function Results({ exam, state, onRestart, onExit, onRedoFailed, reviewOnly, resultId }: Props) {
const { t, i18n } = useTranslation()
const { answers, startedAt, completedAt } = state
const elapsedMs = (completedAt ?? Date.now()) - startedAt
@@ -167,6 +169,8 @@ export default function Results({ exam, state, onRestart, onExit, onRedoFailed,
</div>
)}
{resultId != null && <TutorChatPanel resultId={resultId} />}
<h2 className="review-heading">{t('results.review')}</h2>
{exam.questions.map((q, i) => {
const g = graded[i]
@@ -364,3 +368,121 @@ function AiDetail({ detail, type }: { detail: unknown; type: string }) {
return null
}
function TutorChatPanel({ resultId }: { resultId: number }) {
const { t, i18n } = useTranslation()
const [open, setOpen] = useState(false)
const [messages, setMessages] = useState<aiApi.TutorChatMessage[]>([])
const [input, setInput] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const send = async () => {
const text = input.trim()
if (!text || busy) return
const next: aiApi.TutorChatMessage[] = [...messages, { role: 'user', content: text }]
setMessages(next)
setInput('')
setBusy(true)
setErr(null)
try {
const res = await aiApi.tutorChat({
resultId,
messages: next,
language: i18n.language || 'en',
})
setMessages((prev) => [...prev, { role: 'assistant', content: res.reply }])
} catch (e) {
setErr((e as Error).message)
setMessages((prev) => prev.slice(0, -1))
setInput(text)
} finally {
setBusy(false)
}
}
return (
<div style={{ margin: '24px 0', border: '1px solid var(--line-2)', borderRadius: 10, background: 'var(--paper-2)' }}>
<button
onClick={() => setOpen((v) => !v)}
style={{
width: '100%',
background: 'none',
border: 'none',
padding: '14px 16px',
fontSize: 14,
fontWeight: 600,
textAlign: 'left',
cursor: 'pointer',
color: 'var(--ink-1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span>{t('results.tutor.title')}</span>
<span style={{ color: 'var(--ink-4)' }}>{open ? '−' : '+'}</span>
</button>
{open && (
<div style={{ padding: '0 16px 16px' }}>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginBottom: 10 }}>
{t('results.tutor.subtitle')}
</div>
{messages.length === 0 && (
<div style={{ fontSize: 12, color: 'var(--ink-4)', marginBottom: 10, fontStyle: 'italic' }}>
{t('results.tutor.placeholder')}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 10 }}>
{messages.map((m, i) => (
<div
key={i}
style={{
alignSelf: m.role === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '85%',
padding: '8px 12px',
borderRadius: 8,
background: m.role === 'user' ? 'var(--accent-weak, var(--paper))' : 'var(--paper)',
border: '1px solid var(--line-1)',
fontSize: 13,
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
}}
>
{m.content}
</div>
))}
</div>
{err && (
<div style={{ marginBottom: 8, padding: '6px 10px', borderRadius: 6, background: 'var(--wrong-weak)', color: 'var(--wrong)', fontSize: 12 }}>
{err}
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<input
className="input"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
void send()
}
}}
placeholder={t('results.tutor.inputPlaceholder')}
disabled={busy}
style={{ flex: 1, fontSize: 13 }}
/>
<button
className="btn btn-primary btn-sm"
onClick={() => void send()}
disabled={busy || !input.trim()}
>
{busy ? t('common.loading') : t('results.tutor.send')}
</button>
</div>
</div>
)}
</div>
)
}