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

+8 -1
View File
@@ -36,7 +36,14 @@
"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)"
"Bash(Select-Object Name)",
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" stash list)",
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" stash pop)",
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" diff --stat HEAD)",
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" checkout -- .claude/settings.local.json)",
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" diff HEAD -- frontend/src/components/Overview.tsx)",
"Bash(npm test *)",
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" diff HEAD -- backend/src/validators/adminSchemas.ts backend/src/validators/adminSchemas.test.ts)"
]
}
}
+2
View File
@@ -12,6 +12,7 @@ import setupRoutes from './routes/setup.js'
import gradeRoutes from './routes/grade.js'
import shareRoutes from './routes/share.js'
import collectionRoutes from './routes/collections.js'
import aiRoutes from './routes/ai.js'
import { errorHandler } from './middleware/error.js'
import { config } from './config.js'
import path from 'path'
@@ -64,6 +65,7 @@ app.use('/api/auth', authRoutes)
app.use('/api/exams', examRoutes)
app.use('/api/results', resultRoutes)
app.use('/api/grade', gradeRoutes)
app.use('/api/ai', aiRoutes)
app.use('/api/admin', adminRoutes)
app.use('/api/share', shareRoutes)
app.use('/api/collections', collectionRoutes)
+143
View File
@@ -0,0 +1,143 @@
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 { generateQuestions, getHint, tutorChat } from '../services/aiAssist.js'
import { getResultById } from '../repositories/resultRepo.js'
const router = Router()
router.use(requireAuth)
const generateSchema = z.object({
topic: z.string().min(1).max(2000),
type: z.enum([
'multiple-choice',
'multiple-select',
'short-answer',
'translation',
'math-input',
'essay',
'match',
'order',
]),
count: z.number().int().min(1).max(10),
language: z.string().min(1).max(20),
context: z.string().max(8000).optional(),
})
router.post(
'/generate-question',
validateBody(generateSchema),
asyncHandler(async (req, res) => {
const body = req.body as z.infer<typeof generateSchema>
const questions = await generateQuestions(body)
res.json({ questions })
}),
)
const hintSchema = z.object({
question: z.record(z.unknown()),
language: z.string().min(1).max(20),
})
router.post(
'/hint',
validateBody(hintSchema),
asyncHandler(async (req, res) => {
const body = req.body as z.infer<typeof hintSchema>
const result = await getHint(body)
res.json(result)
}),
)
const tutorSchema = z.object({
resultId: z.number().int().positive(),
messages: z
.array(
z.object({
role: z.enum(['user', 'assistant']),
content: z.string().min(1).max(4000),
}),
)
.min(1)
.max(40),
language: z.string().min(1).max(20),
})
interface TokenBucket {
tokens: number
lastRefill: number
}
const TUTOR_RATE_LIMIT = 30
const TUTOR_REFILL_MS = 60 * 60 * 1000
const tutorBuckets = new Map<number, TokenBucket>()
function consumeTutorToken(userId: number): boolean {
const now = Date.now()
const bucket = tutorBuckets.get(userId) ?? { tokens: TUTOR_RATE_LIMIT, lastRefill: now }
const elapsed = now - bucket.lastRefill
if (elapsed >= TUTOR_REFILL_MS) {
bucket.tokens = TUTOR_RATE_LIMIT
bucket.lastRefill = now
}
if (bucket.tokens <= 0) {
tutorBuckets.set(userId, bucket)
return false
}
bucket.tokens -= 1
tutorBuckets.set(userId, bucket)
return true
}
router.post(
'/tutor-chat',
validateBody(tutorSchema),
asyncHandler(async (req, res) => {
const body = req.body as z.infer<typeof tutorSchema>
const userId = req.user!.userId
if (!consumeTutorToken(userId)) {
res.status(429).json({ error: 'Tutor chat rate limit reached. Please try again later.' })
return
}
const result = getResultById(body.resultId, userId)
if (!result) {
res.status(404).json({ error: 'Result not found' })
return
}
let exam: Record<string, unknown> = {}
let state: Record<string, unknown> = {}
try {
exam = JSON.parse(result.exam_json)
} catch {
res.status(500).json({ error: 'Stored exam data is corrupted' })
return
}
try {
state = JSON.parse(result.state_json)
} catch {
state = {}
}
const reply = await tutorChat({
exam,
state,
scoreSummary: {
title: result.title,
scorePoints: result.score_points,
totalPoints: result.total_points,
correctCount: result.correct_count,
gradedCount: result.graded_count,
},
messages: body.messages,
language: body.language,
})
res.json(reply)
}),
)
export default router
+259
View File
@@ -0,0 +1,259 @@
import { z } from 'zod'
import { getAIClient, getAIModel } from './aiClient.js'
export type QuestionType =
| 'multiple-choice'
| 'multiple-select'
| 'short-answer'
| 'translation'
| 'math-input'
| 'essay'
| 'match'
| 'order'
const choiceSchema = z.object({
id: z.string().min(1),
text: z.string(),
})
const matchItemSchema = z.object({
id: z.string().min(1),
text: z.string(),
})
const generatedQuestionSchema = z.object({
type: z.enum([
'multiple-choice',
'multiple-select',
'short-answer',
'translation',
'math-input',
'essay',
'match',
'order',
]),
prompt: z.string().min(1),
category: z.string().optional(),
points: z.number().int().positive().optional(),
explanation: z.string().optional(),
answer: z.union([z.string(), z.array(z.string()), z.record(z.string())]).optional(),
acceptedForms: z.array(z.string()).optional(),
caseSensitive: z.boolean().optional(),
sourceLanguage: z.string().optional(),
targetLanguage: z.string().optional(),
choices: z.array(choiceSchema).optional(),
minWords: z.number().int().nonnegative().optional(),
rubric: z.array(z.string()).optional(),
left: z.array(matchItemSchema).optional(),
right: z.array(matchItemSchema).optional(),
items: z.array(matchItemSchema).optional(),
})
export type GeneratedQuestion = z.infer<typeof generatedQuestionSchema>
export interface GenerateQuestionsRequest {
topic: string
type: QuestionType
count: number
language: string
context?: string
}
function shapeHintForType(type: QuestionType): string {
switch (type) {
case 'multiple-choice':
return `Required fields: prompt (string), choices (array of {id, text}; ids should be short like "a","b","c","d"; provide 3-4 plausible distractors), answer (string id of the correct choice).`
case 'multiple-select':
return `Required fields: prompt (string), choices (array of {id, text}; ids should be short like "a","b","c","d"), answer (array of correct choice ids).`
case 'short-answer':
return `Required fields: prompt (string), answer (string), acceptedForms (array of acceptable alternate strings, may be empty), caseSensitive (boolean, usually false).`
case 'translation':
return `Required fields: prompt (the source text), sourceLanguage (e.g. "en"), targetLanguage (e.g. "es"), answer (the canonical translation), acceptedForms (other valid translations).`
case 'math-input':
return `Required fields: prompt (string, may include LaTeX), answer (LaTeX string), acceptedForms (array of equivalent LaTeX forms).`
case 'essay':
return `Required fields: prompt (string), minWords (integer, e.g. 100), rubric (array of 3-5 short rubric criteria strings).`
case 'match':
return `Required fields: prompt (string), left (array of {id, text} 3-5 items), right (array of {id, text} 3-5 items), answer (object mapping left.id -> right.id).`
case 'order':
return `Required fields: prompt (string), items (array of {id, text} 3-6 items in any order), answer (array of item ids in the correct order).`
}
}
export async function generateQuestions(req: GenerateQuestionsRequest): Promise<GeneratedQuestion[]> {
const client = getAIClient()
if (!client) {
throw new Error('AI is not configured. Please set provider and API key in admin settings.')
}
const count = Math.max(1, Math.min(10, Math.floor(req.count)))
const model = getAIModel()
const shape = shapeHintForType(req.type)
const prompt = `You are an exam author. Generate ${count} high-quality "${req.type}" exam question(s) about the following topic.
Topic: ${req.topic}
${req.context ? `\nAdditional context / source material:\n${req.context}\n` : ''}
Write all user-facing text (prompts, choices, rubric items, explanations) in ${req.language}.
${shape}
Respond ONLY with a JSON object of the form:
{ "questions": [ ... ${count} question object(s) ... ] }
Each question object must have "type": "${req.type}" and the fields listed above. Do not include "id" — that will be assigned later. Do not include any text outside the JSON object.`
const completion = await client.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
temperature: 0.6,
max_completion_tokens: 1800,
})
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 { questions?: unknown }
if (!Array.isArray(obj.questions)) {
throw new Error('AI response did not include a "questions" array')
}
const validated: GeneratedQuestion[] = []
for (const q of obj.questions) {
const result = generatedQuestionSchema.safeParse(q)
if (result.success && result.data.type === req.type) {
validated.push(result.data)
}
}
if (validated.length === 0) {
throw new Error('AI returned no valid questions for the requested type')
}
return validated
}
export interface HintRequest {
question: Record<string, unknown>
language: string
}
const HINT_STRIP_KEYS = ['answer', 'acceptedForms']
function stripAnswerFields(question: Record<string, unknown>): Record<string, unknown> {
const clone: Record<string, unknown> = {}
for (const [k, v] of Object.entries(question)) {
if (HINT_STRIP_KEYS.includes(k)) continue
clone[k] = v
}
return clone
}
export async function getHint(req: HintRequest): Promise<{ hint: string }> {
const client = getAIClient()
if (!client) {
throw new Error('AI is not configured. Please set provider and API key in admin settings.')
}
const safeQuestion = stripAnswerFields(req.question)
const model = getAIModel()
const completion = await client.chat.completions.create({
model,
messages: [
{
role: 'system',
content: `You are a tutor helping a student during an exam. Give exactly one short hint (1-2 sentences) in ${req.language} that nudges the student toward the right approach. Never reveal the answer, never restate the answer in different words, and never give the answer to a multiple-choice option. Focus on the concept or strategy. Respond ONLY with a JSON object: { "hint": string }.`,
},
{
role: 'user',
content: `Question:\n${JSON.stringify(safeQuestion)}`,
},
],
response_format: { type: 'json_object' },
temperature: 0.4,
max_completion_tokens: 160,
})
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 hint = typeof obj.hint === 'string' ? obj.hint.trim() : ''
if (!hint) {
throw new Error('AI did not return a hint')
}
return { hint }
}
export interface TutorChatMessage {
role: 'user' | 'assistant'
content: string
}
export interface TutorChatRequest {
exam: Record<string, unknown>
state: Record<string, unknown>
scoreSummary: {
title: string
scorePoints: number
totalPoints: number
correctCount: number
gradedCount: number
}
messages: TutorChatMessage[]
language: string
}
export async function tutorChat(req: TutorChatRequest): Promise<{ reply: string }> {
const client = getAIClient()
if (!client) {
throw new Error('AI is not configured. Please set provider and API key in admin settings.')
}
const model = getAIModel()
const recent = req.messages.slice(-10)
const systemPrompt = `You are a friendly, concise tutor reviewing a completed exam attempt with a student. Reply in ${req.language}. Keep answers short (under 200 words). When the student references a question by number, use the exam's question order. You may explain mistakes, give similar practice problems, or clarify rubric criteria. You may reference the student's actual answers and the AI feedback already produced. Never invent score numbers — use only what's in the exam summary.
Exam summary:
- Title: ${req.scoreSummary.title}
- Score: ${req.scoreSummary.scorePoints}/${req.scoreSummary.totalPoints}
- Correct: ${req.scoreSummary.correctCount}/${req.scoreSummary.gradedCount}
Exam (JSON): ${JSON.stringify(req.exam).slice(0, 8000)}
Student's submission state (answers, flags, etc., JSON): ${JSON.stringify(req.state).slice(0, 4000)}`
const completion = await client.chat.completions.create({
model,
messages: [
{ role: 'system', content: systemPrompt },
...recent.map((m) => ({ role: m.role, content: m.content })),
],
temperature: 0.5,
max_completion_tokens: 600,
})
const reply = completion.choices[0]?.message?.content?.trim()
if (!reply) {
throw new Error('Empty response from AI provider')
}
return { reply }
}
+22
View File
@@ -0,0 +1,22 @@
import OpenAI from 'openai'
import { getSetting } from '../repositories/settingsRepo.js'
export interface AIClientOverride {
provider?: string
apiKey?: string
baseURL?: string
}
export function getAIClient(override?: AIClientOverride): OpenAI | null {
const provider = override?.provider ?? getSetting('ai_provider')
const apiKey = override?.apiKey ?? getSetting('ai_api_key')
if (!provider || !apiKey) return null
const customBase = override?.baseURL ?? getSetting('ai_base_url')
const baseURL = provider === 'openai' ? undefined : (customBase || undefined)
return new OpenAI({ apiKey, baseURL })
}
export function getAIModel(override?: string): string {
return override || getSetting('ai_model') || 'gpt-4o-mini'
}
+3 -14
View File
@@ -1,5 +1,4 @@
import OpenAI from 'openai'
import { getSetting } from '../repositories/settingsRepo.js'
import { getAIClient, getAIModel } from './aiClient.js'
export interface GradingResult {
suggestedGrade: number
@@ -14,16 +13,6 @@ interface GradeRequest {
language: string
}
function getClient(override?: { provider?: string; apiKey?: string; baseURL?: string }): OpenAI | null {
const provider = override?.provider ?? getSetting('ai_provider')
const apiKey = override?.apiKey ?? getSetting('ai_api_key')
if (!provider || !apiKey) return null
const customBase = override?.baseURL ?? 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
@@ -145,12 +134,12 @@ export async function gradeQuestionWithAI(
req: GradeRequest,
override?: { provider?: string; apiKey?: string; baseURL?: string; model?: string },
): Promise<GradingResult> {
const client = getClient(override)
const client = getAIClient(override)
if (!client) {
throw new Error('AI grading is not configured. Please set provider and API key in admin settings.')
}
const model = (override?.model ?? getSetting('ai_model')) || 'gpt-4o-mini'
const model = getAIModel(override?.model)
const prompt = buildPrompt(req)
const completion = await client.chat.completions.create({
+3 -13
View File
@@ -1,5 +1,4 @@
import OpenAI from 'openai'
import { getSetting } from '../repositories/settingsRepo.js'
import { getAIClient, getAIModel } from './aiClient.js'
export interface WeakArea {
category: string
@@ -23,15 +22,6 @@ interface ResultSummary {
categories: Record<string, { score: number; total: number }>
}
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 calculateStreak(results: Array<{ completed_at: number }>): number {
if (results.length === 0) return 0
@@ -125,12 +115,12 @@ export async function analyzeResults(
.sort((a, b) => a.avgScore - b.avgScore)
.slice(0, 5)
const client = getClient()
const client = getAIClient()
if (!client || weakAreas.length === 0) {
return { weakAreas, studyTips: [], streakDays }
}
const model = getSetting('ai_model') || 'gpt-4o-mini'
const model = getAIModel()
const prompt = `You are a study coach. A student has taken several practice exams. Based on their performance per category, suggest 2-3 concise study tips to help them improve in their weakest areas. Respond ONLY with a JSON object containing:
- "studyTips": string[] (2-3 short tips in ${language})
+8
View File
@@ -50,6 +50,7 @@ function AppInner() {
const { pathname } = useLocation();
const [exam, setExam] = useState<Exam | null>(null);
const [examState, setExamState] = useState<ExamState | null>(null);
const [reviewResultId, setReviewResultId] = useState<number | null>(null);
const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory());
const [authModalOpen, setAuthModalOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
@@ -98,14 +99,20 @@ function AppInner() {
if (pathname.startsWith("/review/") && !exam) {
const reviewExam = localStorage.getItem('brainwave.review.exam')
const reviewState = localStorage.getItem('brainwave.review.state')
const reviewResultIdRaw = localStorage.getItem('brainwave.review.resultId')
if (reviewExam && reviewState) {
try {
const parsedExam = JSON.parse(reviewExam) as Exam
const parsedState = JSON.parse(reviewState) as ExamState
setExam(parsedExam)
setExamState(withNotes(parsedState))
if (reviewResultIdRaw) {
const id = Number(reviewResultIdRaw)
if (Number.isFinite(id) && id > 0) setReviewResultId(id)
}
localStorage.removeItem('brainwave.review.exam')
localStorage.removeItem('brainwave.review.state')
localStorage.removeItem('brainwave.review.resultId')
} catch {
// ignore parse errors
}
@@ -530,6 +537,7 @@ function AppInner() {
onRestart={restart}
onExit={exit}
reviewOnly={true}
resultId={reviewResultId}
/>
) : (
<Navigate to="/" replace />
+63
View File
@@ -0,0 +1,63 @@
import { apiFetch } from '../utils/api.js'
import type { Question, QuestionType } from '../types'
export interface GenerateQuestionsRequest {
topic: string
type: QuestionType
count: number
language: string
context?: string
}
export interface GeneratedQuestion extends Omit<Question, 'id'> {
id?: string
}
export interface GenerateQuestionsResponse {
questions: GeneratedQuestion[]
}
export function generateQuestions(req: GenerateQuestionsRequest): Promise<GenerateQuestionsResponse> {
return apiFetch('/api/ai/generate-question', {
method: 'POST',
body: JSON.stringify(req),
})
}
export interface HintRequest {
question: Record<string, unknown>
language: string
}
export interface HintResponse {
hint: string
}
export function getHint(req: HintRequest): Promise<HintResponse> {
return apiFetch('/api/ai/hint', {
method: 'POST',
body: JSON.stringify(req),
})
}
export interface TutorChatMessage {
role: 'user' | 'assistant'
content: string
}
export interface TutorChatRequest {
resultId: number
messages: TutorChatMessage[]
language: string
}
export interface TutorChatResponse {
reply: string
}
export function tutorChat(req: TutorChatRequest): Promise<TutorChatResponse> {
return apiFetch('/api/ai/tutor-chat', {
method: 'POST',
body: JSON.stringify(req),
})
}
+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>
)
}
+22 -2
View File
@@ -108,7 +108,12 @@
"flagHint": "flag",
"selectHint": "select",
"exit": "Exit",
"untimed": "Untimed"
"untimed": "Untimed",
"needHint": "💡 Need a hint?",
"hintLoading": "Thinking…",
"hintShown": "💡 Hint shown",
"hintTitle": "Get a hint without revealing the answer",
"hintLabel": "Hint"
},
"results": {
"ai": {
@@ -120,6 +125,13 @@
"rubricBreakdown": "Rubric breakdown",
"error": "AI grading failed — please grade manually"
},
"tutor": {
"title": "💬 Ask the tutor",
"subtitle": "Ask follow-up questions about this attempt — explanations, similar problems, or rubric details.",
"placeholder": "e.g. \"Why was Q2 wrong?\" or \"Give me a similar problem to practice.\"",
"inputPlaceholder": "Ask a question…",
"send": "Send"
},
"reviewing": "Reviewing past attempt",
"examComplete": "Exam complete",
"scored": "You scored ",
@@ -404,7 +416,15 @@
"choicePlaceholder": "Choice {{id}}",
"addItem": "+ Add Item",
"leftItemPlaceholder": "Left item {{index}}",
"rightItemPlaceholder": "Right item {{index}}"
"rightItemPlaceholder": "Right item {{index}}",
"aiGenerate": "✨ Generate with AI",
"aiClose": "✕ Close AI",
"aiPanelTitle": "Generate questions",
"aiTopicPlaceholder": "Topic, e.g. \"Pythagorean theorem\"",
"aiContextPlaceholder": "Optional notes or source material to ground the questions in…",
"aiGenerateNow": "Generate",
"aiGenerating": "Generating…",
"aiTopicRequired": "Please enter a topic"
},
"setup": {
"welcome": "Welcome to BrainWave",
+22 -2
View File
@@ -108,7 +108,12 @@
"flagHint": "markeren",
"selectHint": "selecteren",
"exit": "Afsluiten",
"untimed": "Ongetimed"
"untimed": "Ongetimed",
"needHint": "💡 Hint nodig?",
"hintLoading": "Aan het denken…",
"hintShown": "💡 Hint getoond",
"hintTitle": "Krijg een hint zonder dat het antwoord wordt verklapt",
"hintLabel": "Hint"
},
"results": {
"ai": {
@@ -120,6 +125,13 @@
"rubricBreakdown": "Rubric-uitsplitsing",
"error": "AI-beoordeling mislukt — beoordeel handmatig"
},
"tutor": {
"title": "💬 Vraag de tutor",
"subtitle": "Stel vervolgvragen over deze poging — uitleg, vergelijkbare opgaven of rubric-details.",
"placeholder": "bijv. \"Waarom was vraag 2 fout?\" of \"Geef me een vergelijkbare opgave.\"",
"inputPlaceholder": "Stel een vraag…",
"send": "Verstuur"
},
"reviewing": "Eerdere poging bekijken",
"examComplete": "Examen voltooid",
"scored": "Je score: ",
@@ -402,7 +414,15 @@
"choicePlaceholder": "Keuze {{id}}",
"addItem": "+ Item toevoegen",
"leftItemPlaceholder": "Link item {{index}}",
"rightItemPlaceholder": "Recht item {{index}}"
"rightItemPlaceholder": "Recht item {{index}}",
"aiGenerate": "✨ Genereer met AI",
"aiClose": "✕ AI sluiten",
"aiPanelTitle": "Vragen genereren",
"aiTopicPlaceholder": "Onderwerp, bijv. \"Stelling van Pythagoras\"",
"aiContextPlaceholder": "Optionele aantekeningen of bronmateriaal om de vragen op te baseren…",
"aiGenerateNow": "Genereer",
"aiGenerating": "Bezig met genereren…",
"aiTopicRequired": "Voer een onderwerp in"
},
"setup": {
"welcome": "Welkom bij BrainWave",