Public Access
feat: integrate AI features for question generation and tutoring
- 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:
1 parent
1ec1633dd9
commit
63aa9b572a
16 files changed
+881
-36
No files matched your search
@@ -36,7 +36,14 @@
|
|||||||
"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(grep -E \"\\\\.tsx$\")",
|
||||||
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\Laurens Bartelds\\\\Documents\\\\Gitea\\\\brainwave\" -Force)",
|
"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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import setupRoutes from './routes/setup.js'
|
|||||||
import gradeRoutes from './routes/grade.js'
|
import gradeRoutes from './routes/grade.js'
|
||||||
import shareRoutes from './routes/share.js'
|
import shareRoutes from './routes/share.js'
|
||||||
import collectionRoutes from './routes/collections.js'
|
import collectionRoutes from './routes/collections.js'
|
||||||
|
import aiRoutes from './routes/ai.js'
|
||||||
import { errorHandler } from './middleware/error.js'
|
import { errorHandler } from './middleware/error.js'
|
||||||
import { config } from './config.js'
|
import { config } from './config.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
@@ -64,6 +65,7 @@ app.use('/api/auth', authRoutes)
|
|||||||
app.use('/api/exams', examRoutes)
|
app.use('/api/exams', examRoutes)
|
||||||
app.use('/api/results', resultRoutes)
|
app.use('/api/results', resultRoutes)
|
||||||
app.use('/api/grade', gradeRoutes)
|
app.use('/api/grade', gradeRoutes)
|
||||||
|
app.use('/api/ai', aiRoutes)
|
||||||
app.use('/api/admin', adminRoutes)
|
app.use('/api/admin', adminRoutes)
|
||||||
app.use('/api/share', shareRoutes)
|
app.use('/api/share', shareRoutes)
|
||||||
app.use('/api/collections', collectionRoutes)
|
app.use('/api/collections', collectionRoutes)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import OpenAI from 'openai'
|
import { getAIClient, getAIModel } from './aiClient.js'
|
||||||
import { getSetting } from '../repositories/settingsRepo.js'
|
|
||||||
|
|
||||||
export interface GradingResult {
|
export interface GradingResult {
|
||||||
suggestedGrade: number
|
suggestedGrade: number
|
||||||
@@ -14,16 +13,6 @@ interface GradeRequest {
|
|||||||
language: string
|
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 {
|
function buildPrompt(req: GradeRequest): string {
|
||||||
const { type, question, answer, language } = req
|
const { type, question, answer, language } = req
|
||||||
|
|
||||||
@@ -145,12 +134,12 @@ export async function gradeQuestionWithAI(
|
|||||||
req: GradeRequest,
|
req: GradeRequest,
|
||||||
override?: { provider?: string; apiKey?: string; baseURL?: string; model?: string },
|
override?: { provider?: string; apiKey?: string; baseURL?: string; model?: string },
|
||||||
): Promise<GradingResult> {
|
): Promise<GradingResult> {
|
||||||
const client = getClient(override)
|
const client = getAIClient(override)
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error('AI grading is not configured. Please set provider and API key in admin settings.')
|
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 prompt = buildPrompt(req)
|
||||||
|
|
||||||
const completion = await client.chat.completions.create({
|
const completion = await client.chat.completions.create({
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import OpenAI from 'openai'
|
import { getAIClient, getAIModel } from './aiClient.js'
|
||||||
import { getSetting } from '../repositories/settingsRepo.js'
|
|
||||||
|
|
||||||
export interface WeakArea {
|
export interface WeakArea {
|
||||||
category: string
|
category: string
|
||||||
@@ -23,15 +22,6 @@ interface ResultSummary {
|
|||||||
categories: Record<string, { score: number; total: number }>
|
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 {
|
function calculateStreak(results: Array<{ completed_at: number }>): number {
|
||||||
if (results.length === 0) return 0
|
if (results.length === 0) return 0
|
||||||
@@ -125,12 +115,12 @@ export async function analyzeResults(
|
|||||||
.sort((a, b) => a.avgScore - b.avgScore)
|
.sort((a, b) => a.avgScore - b.avgScore)
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
|
|
||||||
const client = getClient()
|
const client = getAIClient()
|
||||||
if (!client || weakAreas.length === 0) {
|
if (!client || weakAreas.length === 0) {
|
||||||
return { weakAreas, studyTips: [], streakDays }
|
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:
|
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})
|
- "studyTips": string[] (2-3 short tips in ${language})
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ function AppInner() {
|
|||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const [exam, setExam] = useState<Exam | null>(null);
|
const [exam, setExam] = useState<Exam | null>(null);
|
||||||
const [examState, setExamState] = useState<ExamState | null>(null);
|
const [examState, setExamState] = useState<ExamState | null>(null);
|
||||||
|
const [reviewResultId, setReviewResultId] = useState<number | null>(null);
|
||||||
const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory());
|
const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory());
|
||||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
@@ -98,14 +99,20 @@ function AppInner() {
|
|||||||
if (pathname.startsWith("/review/") && !exam) {
|
if (pathname.startsWith("/review/") && !exam) {
|
||||||
const reviewExam = localStorage.getItem('brainwave.review.exam')
|
const reviewExam = localStorage.getItem('brainwave.review.exam')
|
||||||
const reviewState = localStorage.getItem('brainwave.review.state')
|
const reviewState = localStorage.getItem('brainwave.review.state')
|
||||||
|
const reviewResultIdRaw = localStorage.getItem('brainwave.review.resultId')
|
||||||
if (reviewExam && reviewState) {
|
if (reviewExam && reviewState) {
|
||||||
try {
|
try {
|
||||||
const parsedExam = JSON.parse(reviewExam) as Exam
|
const parsedExam = JSON.parse(reviewExam) as Exam
|
||||||
const parsedState = JSON.parse(reviewState) as ExamState
|
const parsedState = JSON.parse(reviewState) as ExamState
|
||||||
setExam(parsedExam)
|
setExam(parsedExam)
|
||||||
setExamState(withNotes(parsedState))
|
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.exam')
|
||||||
localStorage.removeItem('brainwave.review.state')
|
localStorage.removeItem('brainwave.review.state')
|
||||||
|
localStorage.removeItem('brainwave.review.resultId')
|
||||||
} catch {
|
} catch {
|
||||||
// ignore parse errors
|
// ignore parse errors
|
||||||
}
|
}
|
||||||
@@ -530,6 +537,7 @@ function AppInner() {
|
|||||||
onRestart={restart}
|
onRestart={restart}
|
||||||
onExit={exit}
|
onExit={exit}
|
||||||
reviewOnly={true}
|
reviewOnly={true}
|
||||||
|
resultId={reviewResultId}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/" replace />
|
<Navigate to="/" replace />
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import * as examApi from '../api/examApi'
|
import * as examApi from '../api/examApi'
|
||||||
|
import * as aiApi from '../api/aiApi'
|
||||||
import type { Exam, Question, QuestionType, Choice } from '../types'
|
import type { Exam, Question, QuestionType, Choice } from '../types'
|
||||||
|
|
||||||
const QUESTION_TYPES: { value: QuestionType; labelKey: string }[] = [
|
const QUESTION_TYPES: { value: QuestionType; labelKey: string }[] = [
|
||||||
@@ -59,6 +60,7 @@ export default function ExamEditor() {
|
|||||||
const [saveMsg, setSaveMsg] = useState<string | null>(null)
|
const [saveMsg, setSaveMsg] = useState<string | null>(null)
|
||||||
const [hasChanges, setHasChanges] = useState(isNew)
|
const [hasChanges, setHasChanges] = useState(isNew)
|
||||||
const [tags, setTags] = useState<string[]>([])
|
const [tags, setTags] = useState<string[]>([])
|
||||||
|
const [aiPanelOpen, setAiPanelOpen] = useState(false)
|
||||||
|
|
||||||
// Load existing exam
|
// Load existing exam
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -92,6 +94,22 @@ export default function ExamEditor() {
|
|||||||
setSelectedIdx(exam.questions.length)
|
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) => {
|
const removeQuestion = (idx: number) => {
|
||||||
updateExam((draft) => {
|
updateExam((draft) => {
|
||||||
draft.questions.splice(idx, 1)
|
draft.questions.splice(idx, 1)
|
||||||
@@ -202,6 +220,21 @@ export default function ExamEditor() {
|
|||||||
|
|
||||||
<div className="exam-editor-add">
|
<div className="exam-editor-add">
|
||||||
<div className="sidebar-section-label" style={{ marginTop: 24 }}>{t('editor.addQuestion')}</div>
|
<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">
|
<div className="exam-editor-type-grid">
|
||||||
{QUESTION_TYPES.map((tType) => (
|
{QUESTION_TYPES.map((tType) => (
|
||||||
<button
|
<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 ─── */
|
/* ─── Question Editor ─── */
|
||||||
|
|
||||||
interface QEProps {
|
interface QEProps {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import type { Exam, ExamState, Question } from "../types";
|
import type { Exam, ExamState, Question } from "../types";
|
||||||
import { gradeQuestion } from "../utils/grading";
|
import { gradeQuestion } from "../utils/grading";
|
||||||
import { formatTime } from "../utils/formatting";
|
import { formatTime } from "../utils/formatting";
|
||||||
|
import * as aiApi from "../api/aiApi";
|
||||||
import Tex from "./Tex";
|
import Tex from "./Tex";
|
||||||
import QuestionMedia from "./QuestionMedia";
|
import QuestionMedia from "./QuestionMedia";
|
||||||
import QuestionRenderer from "./questions/QuestionRenderer";
|
import QuestionRenderer from "./questions/QuestionRenderer";
|
||||||
@@ -27,7 +28,7 @@ export default function ExamRunner({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
n,
|
n,
|
||||||
}: Readonly<Props>) {
|
}: Readonly<Props>) {
|
||||||
const { t } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const urlIdx = Math.max(0, (Number((n ?? "").replace(/^q/, "")) || 1) - 1);
|
const urlIdx = Math.max(0, (Number((n ?? "").replace(/^q/, "")) || 1) - 1);
|
||||||
const { answers, flags, startedAt, feedbackMode } = state;
|
const { answers, flags, startedAt, feedbackMode } = state;
|
||||||
const currentIdx = urlIdx;
|
const currentIdx = urlIdx;
|
||||||
@@ -40,6 +41,35 @@ export default function ExamRunner({
|
|||||||
setRevealed(!!state.revealed?.[q.id]);
|
setRevealed(!!state.revealed?.[q.id]);
|
||||||
}, [currentIdx, q.id, state.revealed]);
|
}, [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) =>
|
const setAnswer = (val: unknown) =>
|
||||||
setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } }));
|
setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } }));
|
||||||
|
|
||||||
@@ -225,6 +255,26 @@ export default function ExamRunner({
|
|||||||
graded={graded}
|
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" && (
|
{graded && result && q.type !== "essay" && (
|
||||||
<div className={`feedback ${result.correct ? "correct" : "wrong"}`}>
|
<div className={`feedback ${result.correct ? "correct" : "wrong"}`}>
|
||||||
<div className="feedback-label">
|
<div className="feedback-label">
|
||||||
@@ -259,6 +309,20 @@ export default function ExamRunner({
|
|||||||
<button className="btn btn-ghost" onClick={toggleFlag}>
|
<button className="btn btn-ghost" onClick={toggleFlag}>
|
||||||
{flags[q.id] ? t('exam.flagged') : t('exam.flagForReview')}
|
{flags[q.id] ? t('exam.flagged') : t('exam.flagForReview')}
|
||||||
</button>
|
</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" />
|
<span className="spacer" />
|
||||||
{feedbackMode === "immediate" &&
|
{feedbackMode === "immediate" &&
|
||||||
!revealed &&
|
!revealed &&
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export default function MyResults({ onLoadExam }: Props) {
|
|||||||
...(state || {}),
|
...(state || {}),
|
||||||
completedAt: typeof completedAt === 'number' ? completedAt * 1000 : Date.now(),
|
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 })}`)
|
navigate(`/review/${getExamUrlId(data.exam as unknown as { title: string; id?: string })}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message)
|
setError((e as Error).message)
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export default function Overview({ examCount, resultCount, recentResults, onLoad
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
{/* <div
|
||||||
className="workspace-card"
|
className="workspace-card"
|
||||||
style={{ flex: 1, minWidth: 220, padding: 16, display: 'flex', flexDirection: 'column', gap: 8 }}
|
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')}
|
{importing ? t('workspace.importing') : t('workspace.import')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="workspace-card"
|
className="workspace-card"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Tex from './Tex'
|
|||||||
import QuestionMedia from './QuestionMedia'
|
import QuestionMedia from './QuestionMedia'
|
||||||
import QuestionRenderer from './questions/QuestionRenderer'
|
import QuestionRenderer from './questions/QuestionRenderer'
|
||||||
import * as gradeApi from '../api/gradeApi'
|
import * as gradeApi from '../api/gradeApi'
|
||||||
|
import * as aiApi from '../api/aiApi'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
exam: Exam
|
exam: Exam
|
||||||
@@ -15,6 +16,7 @@ interface Props {
|
|||||||
onExit: () => void
|
onExit: () => void
|
||||||
onRedoFailed?: () => void
|
onRedoFailed?: () => void
|
||||||
reviewOnly?: boolean
|
reviewOnly?: boolean
|
||||||
|
resultId?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const AI_TYPES = new Set(['essay', 'short-answer', 'translation', 'math-input', 'match', 'order'])
|
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 { t, i18n } = useTranslation()
|
||||||
const { answers, startedAt, completedAt } = state
|
const { answers, startedAt, completedAt } = state
|
||||||
const elapsedMs = (completedAt ?? Date.now()) - startedAt
|
const elapsedMs = (completedAt ?? Date.now()) - startedAt
|
||||||
@@ -167,6 +169,8 @@ export default function Results({ exam, state, onRestart, onExit, onRedoFailed,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{resultId != null && <TutorChatPanel resultId={resultId} />}
|
||||||
|
|
||||||
<h2 className="review-heading">{t('results.review')}</h2>
|
<h2 className="review-heading">{t('results.review')}</h2>
|
||||||
{exam.questions.map((q, i) => {
|
{exam.questions.map((q, i) => {
|
||||||
const g = graded[i]
|
const g = graded[i]
|
||||||
@@ -364,3 +368,121 @@ function AiDetail({ detail, type }: { detail: unknown; type: string }) {
|
|||||||
|
|
||||||
return null
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -108,7 +108,12 @@
|
|||||||
"flagHint": "flag",
|
"flagHint": "flag",
|
||||||
"selectHint": "select",
|
"selectHint": "select",
|
||||||
"exit": "Exit",
|
"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": {
|
"results": {
|
||||||
"ai": {
|
"ai": {
|
||||||
@@ -120,6 +125,13 @@
|
|||||||
"rubricBreakdown": "Rubric breakdown",
|
"rubricBreakdown": "Rubric breakdown",
|
||||||
"error": "AI grading failed — please grade manually"
|
"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",
|
"reviewing": "Reviewing past attempt",
|
||||||
"examComplete": "Exam complete",
|
"examComplete": "Exam complete",
|
||||||
"scored": "You scored ",
|
"scored": "You scored ",
|
||||||
@@ -404,7 +416,15 @@
|
|||||||
"choicePlaceholder": "Choice {{id}}",
|
"choicePlaceholder": "Choice {{id}}",
|
||||||
"addItem": "+ Add Item",
|
"addItem": "+ Add Item",
|
||||||
"leftItemPlaceholder": "Left item {{index}}",
|
"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": {
|
"setup": {
|
||||||
"welcome": "Welcome to BrainWave",
|
"welcome": "Welcome to BrainWave",
|
||||||
|
|||||||
@@ -108,7 +108,12 @@
|
|||||||
"flagHint": "markeren",
|
"flagHint": "markeren",
|
||||||
"selectHint": "selecteren",
|
"selectHint": "selecteren",
|
||||||
"exit": "Afsluiten",
|
"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": {
|
"results": {
|
||||||
"ai": {
|
"ai": {
|
||||||
@@ -120,6 +125,13 @@
|
|||||||
"rubricBreakdown": "Rubric-uitsplitsing",
|
"rubricBreakdown": "Rubric-uitsplitsing",
|
||||||
"error": "AI-beoordeling mislukt — beoordeel handmatig"
|
"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",
|
"reviewing": "Eerdere poging bekijken",
|
||||||
"examComplete": "Examen voltooid",
|
"examComplete": "Examen voltooid",
|
||||||
"scored": "Je score: ",
|
"scored": "Je score: ",
|
||||||
@@ -402,7 +414,15 @@
|
|||||||
"choicePlaceholder": "Keuze {{id}}",
|
"choicePlaceholder": "Keuze {{id}}",
|
||||||
"addItem": "+ Item toevoegen",
|
"addItem": "+ Item toevoegen",
|
||||||
"leftItemPlaceholder": "Link item {{index}}",
|
"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": {
|
"setup": {
|
||||||
"welcome": "Welkom bij BrainWave",
|
"welcome": "Welkom bij BrainWave",
|
||||||
|
|||||||
Reference in new issue
Block a user