From 54a0ede92ad5f2a5ae12bc74e92655c29acc35a9 Mon Sep 17 00:00:00 2001 From: LaurensBartelds <48316768+LaurensBartelds@users.noreply.github.com> Date: Tue, 5 May 2026 15:37:17 +0200 Subject: [PATCH] feat: implement AI usage tracking and quota management with daily usage records --- backend/src/config.ts | 1 + backend/src/db/migrations.ts | 20 +++ backend/src/repositories/aiUsageRepo.ts | 46 +++++ backend/src/services/aiAssist.ts | 67 ++++--- backend/src/services/aiClient.ts | 23 +++ backend/src/services/aiGrading.ts | 14 +- backend/src/services/studyCoach.ts | 23 ++- backend/src/utils/aiQuota.ts | 40 +++++ backend/src/utils/aiTokens.ts | 29 +++ backend/src/utils/tokens.ts | 2 +- frontend/src/App.tsx | 4 +- frontend/src/api/authApi.ts | 4 + frontend/src/components/MyResults.tsx | 2 +- frontend/src/components/Overview.tsx | 2 +- frontend/src/contexts/AuthContext.tsx | 9 +- frontend/src/utils/api.ts | 19 +- frontend/src/utils/instructions.ts | 226 +++++++++++++++++++++++- 17 files changed, 485 insertions(+), 46 deletions(-) create mode 100644 backend/src/repositories/aiUsageRepo.ts create mode 100644 backend/src/utils/aiQuota.ts create mode 100644 backend/src/utils/aiTokens.ts diff --git a/backend/src/config.ts b/backend/src/config.ts index 5326cec..c96fc8f 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -26,6 +26,7 @@ export const config = { env: env('NODE_ENV', 'development'), port: envInt('PORT', 3001), jwtSecret: env('JWT_SECRET'), + jwtExpiresIn: env('JWT_EXPIRES_IN', '7d'), dbPath: env('DB_PATH', './db/brainwave.db'), corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'), bodyLimit: env('BODY_LIMIT', '100kb'), diff --git a/backend/src/db/migrations.ts b/backend/src/db/migrations.ts index 2da1b6e..d2031e2 100644 --- a/backend/src/db/migrations.ts +++ b/backend/src/db/migrations.ts @@ -49,8 +49,18 @@ export function runMigrations(db: Database.Database): void { updated_at INTEGER DEFAULT (unixepoch()) ); + CREATE TABLE IF NOT EXISTS ai_usage_daily ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + period_date TEXT NOT NULL, + tokens_used INTEGER NOT NULL DEFAULT 0, + requests_used INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER DEFAULT (unixepoch()), + PRIMARY KEY (user_id, period_date) + ); + CREATE INDEX IF NOT EXISTS idx_saved_exams_user ON saved_exams(user_id); CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id); + CREATE INDEX IF NOT EXISTS idx_ai_usage_daily_user ON ai_usage_daily(user_id); `); // ── Add share_token and is_public columns if missing ── @@ -149,6 +159,16 @@ export function runMigrations(db: Database.Database): void { smtp_from: "BrainWave ", smtp_secure: "auto", app_url: "http://localhost:5173", + ai_provider: "", + ai_model: "gpt-4o-mini", + ai_api_key: "", + ai_base_url: "", + ai_model_hint: "", + ai_model_generate: "", + ai_model_grading: "", + ai_model_tutor: "", + ai_model_study_coach: "", + ai_daily_token_limit: "0", }; const upsert = db.prepare(` INSERT INTO site_settings (key, value) VALUES (?, ?) diff --git a/backend/src/repositories/aiUsageRepo.ts b/backend/src/repositories/aiUsageRepo.ts new file mode 100644 index 0000000..225e0fb --- /dev/null +++ b/backend/src/repositories/aiUsageRepo.ts @@ -0,0 +1,46 @@ +import { getDb } from '../db/database.js' + +export interface AiDailyUsage { + tokensUsed: number + requestsUsed: number +} + +function normalizeCount(value: number): number { + if (!Number.isFinite(value) || value <= 0) return 0 + return Math.floor(value) +} + +export function getDailyUsage(userId: number, periodDate: string): AiDailyUsage { + const db = getDb() + const row = db.prepare(` + SELECT tokens_used, requests_used + FROM ai_usage_daily + WHERE user_id = ? AND period_date = ? + `).get(userId, periodDate) as { tokens_used: number; requests_used: number } | undefined + + return { + tokensUsed: row?.tokens_used ?? 0, + requestsUsed: row?.requests_used ?? 0, + } +} + +export function incrementDailyUsage( + userId: number, + periodDate: string, + tokensUsed: number, + requestsUsed = 1, +): void { + const db = getDb() + const tokens = normalizeCount(tokensUsed) + const requests = normalizeCount(requestsUsed) + if (tokens === 0 && requests === 0) return + + db.prepare(` + INSERT INTO ai_usage_daily (user_id, period_date, tokens_used, requests_used) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, period_date) DO UPDATE SET + tokens_used = tokens_used + excluded.tokens_used, + requests_used = requests_used + excluded.requests_used, + updated_at = unixepoch() + `).run(userId, periodDate, tokens, requests) +} diff --git a/backend/src/services/aiAssist.ts b/backend/src/services/aiAssist.ts index 5aa119c..69a46d2 100644 --- a/backend/src/services/aiAssist.ts +++ b/backend/src/services/aiAssist.ts @@ -1,5 +1,6 @@ import { z } from 'zod' -import { getAIClient, getAIModel } from './aiClient.js' +import { getAIClient, getAIModelForTask } from './aiClient.js' +import { AiUsageResult, estimateTokensFromMessages, estimateTokensFromText, estimateTotalTokens, resolveUsageTokens } from '../utils/aiTokens.js' export type QuestionType = | 'multiple-choice' @@ -141,14 +142,14 @@ function shapeHintForType(type: QuestionType): string { } } -export async function generateQuestions(req: GenerateQuestionsRequest): Promise { +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 model = getAIModelForTask('question_generation') 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. @@ -164,6 +165,9 @@ Respond ONLY with a JSON object of the form: 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 promptTokens = estimateTokensFromText(prompt) + const estimatedTokens = estimateTotalTokens(promptTokens, 1800) + const completion = await client.chat.completions.create({ model, messages: [{ role: 'user', content: prompt }], @@ -172,6 +176,8 @@ Each question object must have "type": "${req.type}" and the fields listed above max_completion_tokens: 1800, }) + const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens) + const raw = completion.choices[0]?.message?.content if (!raw) { throw new Error('Empty response from AI provider') @@ -200,7 +206,7 @@ Each question object must have "type": "${req.type}" and the fields listed above if (validated.length === 0) { throw new Error('AI returned no valid questions for the requested type') } - return validated + return { data: validated, usageTokens } } export interface HintRequest { @@ -219,32 +225,39 @@ function stripAnswerFields(question: Record): Record { +export async function getHint(req: HintRequest): Promise> { 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 model = getAIModelForTask('hint') + + const 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)}`, + }, + ] + + const promptTokens = estimateTokensFromMessages(messages) + const estimatedTokens = estimateTotalTokens(promptTokens, 160) 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)}`, - }, - ], + messages, response_format: { type: 'json_object' }, temperature: 0.4, max_completion_tokens: 160, }) + const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens) + const raw = completion.choices[0]?.message?.content if (!raw) { throw new Error('Empty response from AI provider') @@ -260,7 +273,7 @@ export async function getHint(req: HintRequest): Promise<{ hint: string }> { if (!hint) { throw new Error('AI did not return a hint') } - return { hint } + return { data: { hint }, usageTokens } } export interface TutorChatMessage { @@ -282,13 +295,13 @@ export interface TutorChatRequest { language: string } -export async function tutorChat(req: TutorChatRequest): Promise<{ reply: string }> { +export async function tutorChat(req: TutorChatRequest): Promise> { 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 model = getAIModelForTask('tutor_chat') 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. @@ -302,19 +315,25 @@ 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 messages = [ + { role: 'system', content: systemPrompt }, + ...recent.map((m) => ({ role: m.role, content: m.content })), + ] + const promptTokens = estimateTokensFromMessages(messages) + const estimatedTokens = estimateTotalTokens(promptTokens, 600) + const completion = await client.chat.completions.create({ model, - messages: [ - { role: 'system', content: systemPrompt }, - ...recent.map((m) => ({ role: m.role, content: m.content })), - ], + messages, temperature: 0.5, max_completion_tokens: 600, }) + const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens) + const reply = completion.choices[0]?.message?.content?.trim() if (!reply) { throw new Error('Empty response from AI provider') } - return { reply } + return { data: { reply }, usageTokens } } diff --git a/backend/src/services/aiClient.ts b/backend/src/services/aiClient.ts index 36bc1bb..33a2e65 100644 --- a/backend/src/services/aiClient.ts +++ b/backend/src/services/aiClient.ts @@ -7,6 +7,8 @@ export interface AIClientOverride { baseURL?: string } +export type AiTask = 'hint' | 'question_generation' | 'grading' | 'tutor_chat' | 'study_coach' + export function getAIClient(override?: AIClientOverride): OpenAI | null { const provider = override?.provider ?? getSetting('ai_provider') const apiKey = override?.apiKey ?? getSetting('ai_api_key') @@ -20,3 +22,24 @@ export function getAIClient(override?: AIClientOverride): OpenAI | null { export function getAIModel(override?: string): string { return override || getSetting('ai_model') || 'gpt-4o-mini' } + +const TASK_MODEL_KEYS: Record = { + hint: 'ai_model_hint', + question_generation: 'ai_model_generate', + grading: 'ai_model_grading', + tutor_chat: 'ai_model_tutor', + study_coach: 'ai_model_study_coach', +} + +function normalizeModel(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +export function getAIModelForTask(task: AiTask, override?: string): string { + const explicit = normalizeModel(override) + if (explicit) return explicit + + const taskModel = normalizeModel(getSetting(TASK_MODEL_KEYS[task])) + return taskModel || getAIModel() +} diff --git a/backend/src/services/aiGrading.ts b/backend/src/services/aiGrading.ts index 8c1927f..65140cb 100644 --- a/backend/src/services/aiGrading.ts +++ b/backend/src/services/aiGrading.ts @@ -1,4 +1,5 @@ -import { getAIClient, getAIModel } from './aiClient.js' +import { getAIClient, getAIModelForTask } from './aiClient.js' +import { AiUsageResult, estimateTokensFromText, estimateTotalTokens, resolveUsageTokens } from '../utils/aiTokens.js' export interface GradingResult { suggestedGrade: number @@ -133,14 +134,16 @@ Use the human-readable text labels in the arrays.` export async function gradeQuestionWithAI( req: GradeRequest, override?: { provider?: string; apiKey?: string; baseURL?: string; model?: string }, -): Promise { +): Promise> { 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 = getAIModel(override?.model) + const model = getAIModelForTask('grading', override?.model) const prompt = buildPrompt(req) + const promptTokens = estimateTokensFromText(prompt) + const estimatedTokens = estimateTotalTokens(promptTokens, 800) const completion = await client.chat.completions.create({ model, @@ -150,6 +153,8 @@ export async function gradeQuestionWithAI( max_completion_tokens: 800, }) + const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens) + const raw = completion.choices[0]?.message?.content if (!raw) { throw new Error('Empty response from AI provider') @@ -168,8 +173,11 @@ export async function gradeQuestionWithAI( : 0 return { + data: { suggestedGrade, feedback: typeof obj.feedback === 'string' ? obj.feedback : '', detail: obj.detail, + }, + usageTokens, } } diff --git a/backend/src/services/studyCoach.ts b/backend/src/services/studyCoach.ts index 89c6f0a..60a7812 100644 --- a/backend/src/services/studyCoach.ts +++ b/backend/src/services/studyCoach.ts @@ -1,4 +1,5 @@ -import { getAIClient, getAIModel } from './aiClient.js' +import { getAIClient, getAIModelForTask } from './aiClient.js' +import { AiUsageResult, estimateTokensFromText, estimateTotalTokens, resolveUsageTokens } from '../utils/aiTokens.js' export interface WeakArea { category: string @@ -71,7 +72,8 @@ export async function analyzeResults( exam_json: string }>, language = 'en', -): Promise { + options?: { allowAi?: boolean }, +): Promise> { const streakDays = calculateStreak(results) // Build per-category stats from exam_json @@ -116,11 +118,11 @@ export async function analyzeResults( .slice(0, 5) const client = getAIClient() - if (!client || weakAreas.length === 0) { - return { weakAreas, studyTips: [], streakDays } + if (!client || weakAreas.length === 0 || options?.allowAi === false) { + return { data: { weakAreas, studyTips: [], streakDays }, usageTokens: 0 } } - const model = getAIModel() + const model = getAIModelForTask('study_coach') 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}) @@ -129,6 +131,9 @@ Do not include any text outside the JSON object. Weak areas (score %, attempts): ${weakAreas.map((w) => `- ${w.category}: ${w.avgScore}% over ${w.attempts} attempt(s)`).join('\n')}` + const promptTokens = estimateTokensFromText(prompt) + const estimatedTokens = estimateTotalTokens(promptTokens, 400) + try { const completion = await client.chat.completions.create({ model, @@ -138,9 +143,11 @@ ${weakAreas.map((w) => `- ${w.category}: ${w.avgScore}% over ${w.attempts} attem max_completion_tokens: 400, }) + const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens) + const raw = completion.choices[0]?.message?.content if (!raw) { - return { weakAreas, studyTips: [], streakDays } + return { data: { weakAreas, studyTips: [], streakDays }, usageTokens: 0 } } const parsed = JSON.parse(raw) as Record @@ -148,8 +155,8 @@ ${weakAreas.map((w) => `- ${w.category}: ${w.avgScore}% over ${w.attempts} attem ? parsed.studyTips.filter((t): t is string => typeof t === 'string') : [] - return { weakAreas, studyTips: tips, streakDays } + return { data: { weakAreas, studyTips: tips, streakDays }, usageTokens } } catch { - return { weakAreas, studyTips: [], streakDays } + return { data: { weakAreas, studyTips: [], streakDays }, usageTokens: 0 } } } diff --git a/backend/src/utils/aiQuota.ts b/backend/src/utils/aiQuota.ts new file mode 100644 index 0000000..77a9a1a --- /dev/null +++ b/backend/src/utils/aiQuota.ts @@ -0,0 +1,40 @@ +import { getSetting } from '../repositories/settingsRepo.js' +import { getDailyUsage, incrementDailyUsage } from '../repositories/aiUsageRepo.js' + +export interface AiQuotaStatus { + allowed: boolean + limit: number | null + usedTokens: number + periodDate: string +} + +export function getUtcDateKey(date = new Date()): string { + return date.toISOString().slice(0, 10) +} + +export function getDailyTokenLimit(): number | null { + const raw = getSetting('ai_daily_token_limit') ?? '' + const parsed = Number(raw) + if (!Number.isFinite(parsed) || parsed <= 0) return null + return Math.floor(parsed) +} + +export function checkDailyAiQuota(userId: number): AiQuotaStatus { + const periodDate = getUtcDateKey() + const limit = getDailyTokenLimit() + if (!limit) { + return { allowed: true, limit: null, usedTokens: 0, periodDate } + } + + const usage = getDailyUsage(userId, periodDate) + return { + allowed: usage.tokensUsed < limit, + limit, + usedTokens: usage.tokensUsed, + periodDate, + } +} + +export function recordDailyAiUsage(userId: number, periodDate: string, tokens: number): void { + incrementDailyUsage(userId, periodDate, tokens, 1) +} diff --git a/backend/src/utils/aiTokens.ts b/backend/src/utils/aiTokens.ts new file mode 100644 index 0000000..83f68aa --- /dev/null +++ b/backend/src/utils/aiTokens.ts @@ -0,0 +1,29 @@ +export interface AiUsageResult { + data: T + usageTokens: number +} + +export function estimateTokensFromText(text: string): number { + if (!text) return 0 + return Math.max(1, Math.ceil(text.length / 4)) +} + +export function estimateTokensFromMessages(messages: Array<{ content: string }>): number { + return messages.reduce((sum, msg) => sum + estimateTokensFromText(msg.content), 0) +} + +// Conservative estimate when providers omit usage details. +export function estimateTotalTokens(promptTokens: number, maxCompletionTokens: number): number { + const completionEstimate = Math.max(0, Math.round(maxCompletionTokens * 0.6)) + return Math.max(1, promptTokens + completionEstimate) +} + +export function resolveUsageTokens( + usage: { total_tokens?: number } | undefined, + estimate: number, +): number { + if (usage && typeof usage.total_tokens === 'number' && Number.isFinite(usage.total_tokens)) { + return Math.max(0, Math.round(usage.total_tokens)) + } + return estimate +} diff --git a/backend/src/utils/tokens.ts b/backend/src/utils/tokens.ts index 8939a7b..5e8a040 100644 --- a/backend/src/utils/tokens.ts +++ b/backend/src/utils/tokens.ts @@ -10,7 +10,7 @@ export interface JwtPayload { } export function signToken(payload: JwtPayload): string { - return jwt.sign(payload, config.jwtSecret, { expiresIn: '24h' }) + return jwt.sign(payload, config.jwtSecret, { expiresIn: config.jwtExpiresIn }) } export function verifyToken(token: string): JwtPayload { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5c6fd76..92cfed5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -96,7 +96,7 @@ function AppInner() { // Bridge: MyResults stores review data in localStorage; load it on /review/:slug useEffect(() => { - if (pathname.startsWith("/review/") && !exam) { + if (pathname.startsWith("/review/")) { const reviewExam = localStorage.getItem('brainwave.review.exam') const reviewState = localStorage.getItem('brainwave.review.state') const reviewResultIdRaw = localStorage.getItem('brainwave.review.resultId') @@ -118,7 +118,7 @@ function AppInner() { } } } - }, [pathname, exam]); + }, [pathname]); const setExamStateStrict = useCallback>>( (nextState) => { diff --git a/frontend/src/api/authApi.ts b/frontend/src/api/authApi.ts index ae6f136..d6b65fc 100644 --- a/frontend/src/api/authApi.ts +++ b/frontend/src/api/authApi.ts @@ -56,3 +56,7 @@ export function resetPassword(data: { token: string; newPassword: string }): Pro export function deleteAccount(): Promise<{ success: boolean }> { return apiFetch('/api/auth/me', { method: 'DELETE' }) } + +export function logout(): Promise { + return apiFetch('/api/auth/logout', { method: 'POST' }) +} diff --git a/frontend/src/components/MyResults.tsx b/frontend/src/components/MyResults.tsx index cfada63..f58a4ed 100644 --- a/frontend/src/components/MyResults.tsx +++ b/frontend/src/components/MyResults.tsx @@ -158,7 +158,7 @@ export default function MyResults({ onLoadExam }: Props) { {retakingId === r.id ? t('common.loading') : t('results.retakeExam')} {deleteConfirm === r.id ? (
diff --git a/frontend/src/components/Overview.tsx b/frontend/src/components/Overview.tsx index a9a90bf..e04fdfc 100644 --- a/frontend/src/components/Overview.tsx +++ b/frontend/src/components/Overview.tsx @@ -29,7 +29,7 @@ export default function Overview({ examCount, resultCount, recentResults, onLoad useEffect(() => { resultApi.getScoreTrends() .then((data) => setTrends(data)) - .catch(() => {/* ignore */}) + .catch(() => {/* ignore */ }) .finally(() => setTrendsLoading(false)) }, []) diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index f58ba58..c96cead 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -22,7 +22,7 @@ interface AuthContextValue { setUser: (user: User | null) => void login: (email: string, password: string) => Promise register: (email: string, password: string, displayName: string) => Promise - logout: () => void + logout: () => Promise refreshUser: () => Promise } @@ -87,7 +87,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { return 'Registration successful! Please check your email to verify your account.' }, []) - const logout = useCallback(() => { + const logout = useCallback(async () => { + try { + await authApi.logout() + } catch { + // ignore errors — we still want to clear local state + } clearToken() setUser(null) }, []) diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index 91e0534..61c82e5 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -16,8 +16,25 @@ export function clearToken(): void { localStorage.removeItem('brainwave.auth.token') } +function isTokenExpired(token: string): boolean { + try { + const payload = JSON.parse(atob(token.split('.')[1] ?? '')) + const exp = payload.exp + if (typeof exp !== 'number') return false + return Date.now() >= exp * 1000 + } catch { + return true + } +} + export function isAuthenticated(): boolean { - return !!getToken() + const token = getToken() + if (!token) return false + if (isTokenExpired(token)) { + clearToken() + return false + } + return true } export async function apiFetch( diff --git a/frontend/src/utils/instructions.ts b/frontend/src/utils/instructions.ts index c42214d..790df8b 100644 --- a/frontend/src/utils/instructions.ts +++ b/frontend/src/utils/instructions.ts @@ -219,15 +219,216 @@ QUESTION TYPES "answer": ["e1", "e2", "e3", "e4"] } +9. FILL-IN-THE-BLANKS ("fill-in-the-blanks") + Student completes blanks embedded in a text passage. + + Required fields: + "textSegments": ["Before blank", " between blanks", " after blank"] — Array of text strings to interleave with blanks. Must be blanks.length + 1. + "blanks": [ + { "id": "b1", "acceptedAnswers": ["correct word", "alt word"] }, + ... + ] + + Optional per-blank fields: + "acceptedForms": ["alt1"] — Additional accepted forms + "caseSensitive": true|false (default: false) + "ignorePunctuation": true|false (default: false) + "ignoreAccents": true|false (default: false) + + Example: + { + "id": "q9", + "type": "fill-in-the-blanks", + "prompt": "Fill in the missing words.", + "textSegments": ["The capital of France is ", ", and the capital of Japan is ", "."], + "blanks": [ + { "id": "b1", "acceptedAnswers": ["Paris"], "caseSensitive": false }, + { "id": "b2", "acceptedAnswers": ["Tokyo"], "caseSensitive": false } + ] + } + +10. TRUE / FALSE ("true-false") + Student answers a statement with true or false. + + Required fields: + "answer": true or false + + Example: + { + "id": "q10", + "type": "true-false", + "prompt": "The Earth is the third planet from the Sun.", + "answer": true + } + +11. CODE SUBMISSION ("code-submission") + Student writes code in a specific programming language. + + Required fields: + "codeConfig": { "language": "python" } — Programming language identifier + + Optional fields: + "codeConfig": { "language": "python", "starterCode": "def hello():\\n pass" } — Initial code shown in editor + "rubric": ["criterion 1", "criterion 2"] — Criteria for grading + + Example: + { + "id": "q11", + "type": "code-submission", + "prompt": "Write a function that returns the factorial of n.", + "points": 5, + "codeConfig": { "language": "python", "starterCode": "def factorial(n):\\n pass" }, + "rubric": [ + "Handles base case (0! = 1)", + "Uses recursion or iteration correctly", + "Returns correct result for positive integers" + ] + } + +12. DROPDOWN COMPLETION ("dropdown-completion") + Student selects from dropdown menus embedded in a text passage. + + Required fields: + "textSegments": ["Before dropdown", " between dropdowns", " after dropdown"] — Must be dropdowns.length + 1. + "dropdowns": [ + { + "id": "d1", + "options": [{ "id": "a", "text": "Option A" }, { "id": "b", "text": "Option B" }], + "correctOptionId": "a" + } + ] + + Example: + { + "id": "q12", + "type": "dropdown-completion", + "prompt": "Choose the correct words.", + "textSegments": ["Water boils at ", " degrees Celsius at sea level, and freezes at ", "."], + "dropdowns": [ + { + "id": "d1", + "options": [ + { "id": "a", "text": "100" }, + { "id": "b", "text": "90" }, + { "id": "c", "text": "110" } + ], + "correctOptionId": "a" + }, + { + "id": "d2", + "options": [ + { "id": "a", "text": "0" }, + { "id": "b", "text": "-10" }, + { "id": "c", "text": "10" } + ], + "correctOptionId": "a" + } + ] + } + +13. NUMERIC RANGE ("numeric-range") + Student enters a numeric answer within an acceptable tolerance. + + Required fields: + "numericConfig": { "answer": 42, "tolerance": 0.5 } — Correct value and allowed deviation + + Optional fields: + "numericConfig": { "answer": 42, "tolerance": 0.5, "min": 0, "max": 100 } — Bounds for validation + + Example: + { + "id": "q13", + "type": "numeric-range", + "prompt": "What is the approximate speed of sound in air at 20°C (in m/s)?", + "points": 2, + "numericConfig": { "answer": 343, "tolerance": 5, "min": 300, "max": 400 } + } + +14. DICTATION ("dictation") + Student transcribes audio they listen to. + + Required fields: + "audio": { "src": "https://example.com/audio.mp3" } — URL to the audio file + "answer": "string" — Correct transcription + + Optional fields: + "acceptedForms": ["alt transcription 1"] — Other accepted forms + "caseSensitive": true|false (default: false) + "ignorePunctuation": true|false (default: false) + "ignoreAccents": true|false (default: false) + + Example: + { + "id": "q14", + "type": "dictation", + "prompt": "Listen to the audio and type what you hear.", + "audio": { "src": "https://example.com/dictation.mp3" }, + "answer": "The quick brown fox jumps over the lazy dog.", + "caseSensitive": false, + "ignorePunctuation": true + } + +15. GRAMMAR CORRECTION ("grammar-correction") + Student corrects errors in a provided sentence. + + Required fields: + "textToCorrect": "string" — The sentence containing errors + "answer": "string" — The corrected sentence + + Optional fields: + "acceptedForms": ["alt correction 1"] — Other accepted corrected forms + "caseSensitive": true|false (default: false) + "ignorePunctuation": true|false (default: false) + + Example: + { + "id": "q15", + "type": "grammar-correction", + "prompt": "Correct the grammar in the following sentence.", + "textToCorrect": "She don't like apples.", + "answer": "She doesn't like apples.", + "caseSensitive": false + } + +16. FLASHCARD ("flashcard") + Self-study card with front and back. Not graded — student self-assesses. + + Required fields: + "answer": "string" — The back of the card (translation, definition, etc.) + + Optional fields: + "explanation": "string" — Extra info shown after reveal + + Example: + { + "id": "q16", + "type": "flashcard", + "prompt": "Bonjour", + "answer": "Hello (French)", + "explanation": "Informal greeting used throughout the day." + } + +──────────────────────────────────── +LANGUAGE GUIDELINES +──────────────────────────────────── +When writing user-facing text (prompts, choices, explanations, rubric items, hints): + - Write ALL user-facing text in the language the user is asking for (their native/request language). + - Do NOT default to the subject language for explanations, prompts, or rubric items. + +Examples: + - If the user asks for a "French vocabulary test" in English, write prompts and explanations in ENGLISH. Only the target vocabulary (the word being tested) should be in French. + - If the user asks for a "biology exam" in Dutch, write everything in DUTCH, even if the source material is in English. + - Explanations should always be in the user's request language so they can understand why an answer is correct or incorrect. + ──────────────────────────────────── LATEX SUPPORT ──────────────────────────────────── Use LaTeX math in prompts, choices, and explanations: - Inline math: $x^2 + y^2 = z^2$ - - Display math: $$\\\\int_0^1 x^2 \\\\, dx = \\\\frac{1}{3}$$ + - Display math: $$\\int_0^1 x^2 \\, dx = \\frac{1}{3}$$ Remember to double-escape backslashes in JSON strings: - "prompt": "Evaluate $\\\\frac{d}{dx} x^2$" + "prompt": "Evaluate $\\frac{d}{dx} x^2$" ──────────────────────────────────── MEDIA ATTACHMENTS @@ -304,10 +505,29 @@ When asking your LLM to create an exam, include: 4. Which question types to use 5. Desired difficulty level 6. Whether to include a time limit + 7. The language you want all user-facing text written in Example prompt: "Using the attached format guide, create a 10-question exam on World War II. Use a mix of multiple-choice, short-answer, and ordering questions. -Set a 20-minute time limit. Include explanations for each answer." +Set a 20-minute time limit. Write everything in English and include explanations +for each answer." + +──────────────────────────────────── +CLARIFYING QUESTIONS +──────────────────────────────────── +Before generating the exam JSON, you MAY ask 1–3 short clarifying questions if +anything is ambiguous or would significantly improve the quality of the output. +Only ask questions when the user's request is genuinely unclear — do not delay +trivial requests. + +Good clarifying questions include: + - "What is the target difficulty level (beginner, intermediate, advanced)?" + - "Who is the intended audience (age group, class level, professional field)?" + - "Should explanations be in [user's language] even if the subject is a foreign language?" + - "Do you want a mix of question types, or focus on one type?" + - "Should I include a time limit or passing score?" + +Once the user answers, generate the full exam JSON in a single response. ` const blob = new Blob([content], { type: 'text/plain' })