diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7d23d09..9c7662d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/backend/src/index.ts b/backend/src/index.ts index fe5c681..1bf48bb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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) diff --git a/backend/src/routes/ai.ts b/backend/src/routes/ai.ts new file mode 100644 index 0000000..efea805 --- /dev/null +++ b/backend/src/routes/ai.ts @@ -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 + 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 + 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() + +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 + 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 = {} + let state: Record = {} + 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 diff --git a/backend/src/services/aiAssist.ts b/backend/src/services/aiAssist.ts new file mode 100644 index 0000000..e7dba6c --- /dev/null +++ b/backend/src/services/aiAssist.ts @@ -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 + +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 { + 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 + language: string +} + +const HINT_STRIP_KEYS = ['answer', 'acceptedForms'] + +function stripAnswerFields(question: Record): Record { + const clone: Record = {} + 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 + 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 + state: Record + 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 } +} diff --git a/backend/src/services/aiClient.ts b/backend/src/services/aiClient.ts new file mode 100644 index 0000000..36bc1bb --- /dev/null +++ b/backend/src/services/aiClient.ts @@ -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' +} diff --git a/backend/src/services/aiGrading.ts b/backend/src/services/aiGrading.ts index 4bb9e23..8c1927f 100644 --- a/backend/src/services/aiGrading.ts +++ b/backend/src/services/aiGrading.ts @@ -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 { - 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({ diff --git a/backend/src/services/studyCoach.ts b/backend/src/services/studyCoach.ts index a512a92..89c6f0a 100644 --- a/backend/src/services/studyCoach.ts +++ b/backend/src/services/studyCoach.ts @@ -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 } -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}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fe0e3ff..5c6fd76 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -50,6 +50,7 @@ function AppInner() { const { pathname } = useLocation(); const [exam, setExam] = useState(null); const [examState, setExamState] = useState(null); + const [reviewResultId, setReviewResultId] = useState(null); const [history, setHistory] = useState(() => 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} /> ) : ( diff --git a/frontend/src/api/aiApi.ts b/frontend/src/api/aiApi.ts new file mode 100644 index 0000000..3d4d877 --- /dev/null +++ b/frontend/src/api/aiApi.ts @@ -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 { + id?: string +} + +export interface GenerateQuestionsResponse { + questions: GeneratedQuestion[] +} + +export function generateQuestions(req: GenerateQuestionsRequest): Promise { + return apiFetch('/api/ai/generate-question', { + method: 'POST', + body: JSON.stringify(req), + }) +} + +export interface HintRequest { + question: Record + language: string +} + +export interface HintResponse { + hint: string +} + +export function getHint(req: HintRequest): Promise { + 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 { + return apiFetch('/api/ai/tutor-chat', { + method: 'POST', + body: JSON.stringify(req), + }) +} diff --git a/frontend/src/components/ExamEditor.tsx b/frontend/src/components/ExamEditor.tsx index 30075ea..1941793 100644 --- a/frontend/src/components/ExamEditor.tsx +++ b/frontend/src/components/ExamEditor.tsx @@ -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(null) const [hasChanges, setHasChanges] = useState(isNew) const [tags, setTags] = useState([]) + 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), + 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() {
{t('editor.addQuestion')}
+ + {aiPanelOpen && ( + { + appendGeneratedQuestions(qs) + setAiPanelOpen(false) + }} + /> + )}
{QUESTION_TYPES.map((tType) => (