feat: implement AI usage tracking and quota management with daily usage records
Test / backend (push) Failing after 1m6s
Test / frontend (push) Successful in 34s

This commit is contained in:
LaurensBartelds committed 2026-05-05 15:37:17 +02:00
1 parent 1a37209f72
commit 54a0ede92a
17 files changed
+485 -46

No files matched your search

+1
View File
@@ -26,6 +26,7 @@ export const config = {
env: env('NODE_ENV', 'development'), env: env('NODE_ENV', 'development'),
port: envInt('PORT', 3001), port: envInt('PORT', 3001),
jwtSecret: env('JWT_SECRET'), jwtSecret: env('JWT_SECRET'),
jwtExpiresIn: env('JWT_EXPIRES_IN', '7d'),
dbPath: env('DB_PATH', './db/brainwave.db'), dbPath: env('DB_PATH', './db/brainwave.db'),
corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'), corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'),
bodyLimit: env('BODY_LIMIT', '100kb'), bodyLimit: env('BODY_LIMIT', '100kb'),
+20
View File
@@ -49,8 +49,18 @@ export function runMigrations(db: Database.Database): void {
updated_at INTEGER DEFAULT (unixepoch()) 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_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_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 ── // ── Add share_token and is_public columns if missing ──
@@ -149,6 +159,16 @@ export function runMigrations(db: Database.Database): void {
smtp_from: "BrainWave <noreply@brainwave.local>", smtp_from: "BrainWave <noreply@brainwave.local>",
smtp_secure: "auto", smtp_secure: "auto",
app_url: "http://localhost:5173", 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(` const upsert = db.prepare(`
INSERT INTO site_settings (key, value) VALUES (?, ?) INSERT INTO site_settings (key, value) VALUES (?, ?)
+46
View File
@@ -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)
}
+43 -24
View File
@@ -1,5 +1,6 @@
import { z } from 'zod' 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 = export type QuestionType =
| 'multiple-choice' | 'multiple-choice'
@@ -141,14 +142,14 @@ function shapeHintForType(type: QuestionType): string {
} }
} }
export async function generateQuestions(req: GenerateQuestionsRequest): Promise<GeneratedQuestion[]> { export async function generateQuestions(req: GenerateQuestionsRequest): Promise<AiUsageResult<GeneratedQuestion[]>> {
const client = getAIClient() const client = getAIClient()
if (!client) { if (!client) {
throw new Error('AI is not configured. Please set provider and API key in admin settings.') 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 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 shape = shapeHintForType(req.type)
const prompt = `You are an exam author. Generate ${count} high-quality "${req.type}" exam question(s) about the following topic. 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.` 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({ const completion = await client.chat.completions.create({
model, model,
messages: [{ role: 'user', content: prompt }], 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, max_completion_tokens: 1800,
}) })
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
const raw = completion.choices[0]?.message?.content const raw = completion.choices[0]?.message?.content
if (!raw) { if (!raw) {
throw new Error('Empty response from AI provider') 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) { if (validated.length === 0) {
throw new Error('AI returned no valid questions for the requested type') throw new Error('AI returned no valid questions for the requested type')
} }
return validated return { data: validated, usageTokens }
} }
export interface HintRequest { export interface HintRequest {
@@ -219,32 +225,39 @@ function stripAnswerFields(question: Record<string, unknown>): Record<string, un
return clone return clone
} }
export async function getHint(req: HintRequest): Promise<{ hint: string }> { export async function getHint(req: HintRequest): Promise<AiUsageResult<{ hint: string }>> {
const client = getAIClient() const client = getAIClient()
if (!client) { if (!client) {
throw new Error('AI is not configured. Please set provider and API key in admin settings.') throw new Error('AI is not configured. Please set provider and API key in admin settings.')
} }
const safeQuestion = stripAnswerFields(req.question) 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({ const completion = await client.chat.completions.create({
model, model,
messages: [ 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' }, response_format: { type: 'json_object' },
temperature: 0.4, temperature: 0.4,
max_completion_tokens: 160, max_completion_tokens: 160,
}) })
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
const raw = completion.choices[0]?.message?.content const raw = completion.choices[0]?.message?.content
if (!raw) { if (!raw) {
throw new Error('Empty response from AI provider') throw new Error('Empty response from AI provider')
@@ -260,7 +273,7 @@ export async function getHint(req: HintRequest): Promise<{ hint: string }> {
if (!hint) { if (!hint) {
throw new Error('AI did not return a hint') throw new Error('AI did not return a hint')
} }
return { hint } return { data: { hint }, usageTokens }
} }
export interface TutorChatMessage { export interface TutorChatMessage {
@@ -282,13 +295,13 @@ export interface TutorChatRequest {
language: string language: string
} }
export async function tutorChat(req: TutorChatRequest): Promise<{ reply: string }> { export async function tutorChat(req: TutorChatRequest): Promise<AiUsageResult<{ reply: string }>> {
const client = getAIClient() const client = getAIClient()
if (!client) { if (!client) {
throw new Error('AI is not configured. Please set provider and API key in admin settings.') 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 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. 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)}` 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({ const completion = await client.chat.completions.create({
model, model,
messages: [ messages,
{ role: 'system', content: systemPrompt },
...recent.map((m) => ({ role: m.role, content: m.content })),
],
temperature: 0.5, temperature: 0.5,
max_completion_tokens: 600, max_completion_tokens: 600,
}) })
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
const reply = completion.choices[0]?.message?.content?.trim() const reply = completion.choices[0]?.message?.content?.trim()
if (!reply) { if (!reply) {
throw new Error('Empty response from AI provider') throw new Error('Empty response from AI provider')
} }
return { reply } return { data: { reply }, usageTokens }
} }
+23
View File
@@ -7,6 +7,8 @@ export interface AIClientOverride {
baseURL?: string baseURL?: string
} }
export type AiTask = 'hint' | 'question_generation' | 'grading' | 'tutor_chat' | 'study_coach'
export function getAIClient(override?: AIClientOverride): OpenAI | null { export function getAIClient(override?: AIClientOverride): OpenAI | null {
const provider = override?.provider ?? getSetting('ai_provider') const provider = override?.provider ?? getSetting('ai_provider')
const apiKey = override?.apiKey ?? getSetting('ai_api_key') 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 { export function getAIModel(override?: string): string {
return override || getSetting('ai_model') || 'gpt-4o-mini' return override || getSetting('ai_model') || 'gpt-4o-mini'
} }
const TASK_MODEL_KEYS: Record<AiTask, string> = {
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()
}
+11 -3
View File
@@ -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 { export interface GradingResult {
suggestedGrade: number suggestedGrade: number
@@ -133,14 +134,16 @@ Use the human-readable text labels in the arrays.`
export async function gradeQuestionWithAI( 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<AiUsageResult<GradingResult>> {
const client = getAIClient(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 = getAIModel(override?.model) const model = getAIModelForTask('grading', override?.model)
const prompt = buildPrompt(req) const prompt = buildPrompt(req)
const promptTokens = estimateTokensFromText(prompt)
const estimatedTokens = estimateTotalTokens(promptTokens, 800)
const completion = await client.chat.completions.create({ const completion = await client.chat.completions.create({
model, model,
@@ -150,6 +153,8 @@ export async function gradeQuestionWithAI(
max_completion_tokens: 800, max_completion_tokens: 800,
}) })
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
const raw = completion.choices[0]?.message?.content const raw = completion.choices[0]?.message?.content
if (!raw) { if (!raw) {
throw new Error('Empty response from AI provider') throw new Error('Empty response from AI provider')
@@ -168,8 +173,11 @@ export async function gradeQuestionWithAI(
: 0 : 0
return { return {
data: {
suggestedGrade, suggestedGrade,
feedback: typeof obj.feedback === 'string' ? obj.feedback : '', feedback: typeof obj.feedback === 'string' ? obj.feedback : '',
detail: obj.detail, detail: obj.detail,
},
usageTokens,
} }
} }
+15 -8
View File
@@ -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 { export interface WeakArea {
category: string category: string
@@ -71,7 +72,8 @@ export async function analyzeResults(
exam_json: string exam_json: string
}>, }>,
language = 'en', language = 'en',
): Promise<StudyCoachResult> { options?: { allowAi?: boolean },
): Promise<AiUsageResult<StudyCoachResult>> {
const streakDays = calculateStreak(results) const streakDays = calculateStreak(results)
// Build per-category stats from exam_json // Build per-category stats from exam_json
@@ -116,11 +118,11 @@ export async function analyzeResults(
.slice(0, 5) .slice(0, 5)
const client = getAIClient() const client = getAIClient()
if (!client || weakAreas.length === 0) { if (!client || weakAreas.length === 0 || options?.allowAi === false) {
return { weakAreas, studyTips: [], streakDays } 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: 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})
@@ -129,6 +131,9 @@ Do not include any text outside the JSON object.
Weak areas (score %, attempts): Weak areas (score %, attempts):
${weakAreas.map((w) => `- ${w.category}: ${w.avgScore}% over ${w.attempts} attempt(s)`).join('\n')}` ${weakAreas.map((w) => `- ${w.category}: ${w.avgScore}% over ${w.attempts} attempt(s)`).join('\n')}`
const promptTokens = estimateTokensFromText(prompt)
const estimatedTokens = estimateTotalTokens(promptTokens, 400)
try { try {
const completion = await client.chat.completions.create({ const completion = await client.chat.completions.create({
model, model,
@@ -138,9 +143,11 @@ ${weakAreas.map((w) => `- ${w.category}: ${w.avgScore}% over ${w.attempts} attem
max_completion_tokens: 400, max_completion_tokens: 400,
}) })
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
const raw = completion.choices[0]?.message?.content const raw = completion.choices[0]?.message?.content
if (!raw) { if (!raw) {
return { weakAreas, studyTips: [], streakDays } return { data: { weakAreas, studyTips: [], streakDays }, usageTokens: 0 }
} }
const parsed = JSON.parse(raw) as Record<string, unknown> const parsed = JSON.parse(raw) as Record<string, unknown>
@@ -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') ? parsed.studyTips.filter((t): t is string => typeof t === 'string')
: [] : []
return { weakAreas, studyTips: tips, streakDays } return { data: { weakAreas, studyTips: tips, streakDays }, usageTokens }
} catch { } catch {
return { weakAreas, studyTips: [], streakDays } return { data: { weakAreas, studyTips: [], streakDays }, usageTokens: 0 }
} }
} }
+40
View File
@@ -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)
}
+29
View File
@@ -0,0 +1,29 @@
export interface AiUsageResult<T> {
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
}
+1 -1
View File
@@ -10,7 +10,7 @@ export interface JwtPayload {
} }
export function signToken(payload: JwtPayload): string { 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 { export function verifyToken(token: string): JwtPayload {
+2 -2
View File
@@ -96,7 +96,7 @@ function AppInner() {
// Bridge: MyResults stores review data in localStorage; load it on /review/:slug // Bridge: MyResults stores review data in localStorage; load it on /review/:slug
useEffect(() => { useEffect(() => {
if (pathname.startsWith("/review/") && !exam) { if (pathname.startsWith("/review/")) {
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') const reviewResultIdRaw = localStorage.getItem('brainwave.review.resultId')
@@ -118,7 +118,7 @@ function AppInner() {
} }
} }
} }
}, [pathname, exam]); }, [pathname]);
const setExamStateStrict = useCallback<React.Dispatch<React.SetStateAction<ExamState>>>( const setExamStateStrict = useCallback<React.Dispatch<React.SetStateAction<ExamState>>>(
(nextState) => { (nextState) => {
+4
View File
@@ -56,3 +56,7 @@ export function resetPassword(data: { token: string; newPassword: string }): Pro
export function deleteAccount(): Promise<{ success: boolean }> { export function deleteAccount(): Promise<{ success: boolean }> {
return apiFetch('/api/auth/me', { method: 'DELETE' }) return apiFetch('/api/auth/me', { method: 'DELETE' })
} }
export function logout(): Promise<void> {
return apiFetch('/api/auth/logout', { method: 'POST' })
}
+1 -1
View File
@@ -158,7 +158,7 @@ export default function MyResults({ onLoadExam }: Props) {
{retakingId === r.id ? t('common.loading') : t('results.retakeExam')} {retakingId === r.id ? t('common.loading') : t('results.retakeExam')}
</button> </button>
<button className="btn btn-sm" onClick={() => void reviewResult(r)}> <button className="btn btn-sm" onClick={() => void reviewResult(r)}>
{t('common.edit')} {t('results.review')}
</button> </button>
{deleteConfirm === r.id ? ( {deleteConfirm === r.id ? (
<div className="workspace-delete-confirm"> <div className="workspace-delete-confirm">
+1 -1
View File
@@ -29,7 +29,7 @@ export default function Overview({ examCount, resultCount, recentResults, onLoad
useEffect(() => { useEffect(() => {
resultApi.getScoreTrends() resultApi.getScoreTrends()
.then((data) => setTrends(data)) .then((data) => setTrends(data))
.catch(() => {/* ignore */}) .catch(() => {/* ignore */ })
.finally(() => setTrendsLoading(false)) .finally(() => setTrendsLoading(false))
}, []) }, [])
+7 -2
View File
@@ -22,7 +22,7 @@ interface AuthContextValue {
setUser: (user: User | null) => void setUser: (user: User | null) => void
login: (email: string, password: string) => Promise<void> login: (email: string, password: string) => Promise<void>
register: (email: string, password: string, displayName: string) => Promise<string> register: (email: string, password: string, displayName: string) => Promise<string>
logout: () => void logout: () => Promise<void>
refreshUser: () => Promise<void> refreshUser: () => Promise<void>
} }
@@ -87,7 +87,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return 'Registration successful! Please check your email to verify your account.' 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() clearToken()
setUser(null) setUser(null)
}, []) }, [])
+18 -1
View File
@@ -16,8 +16,25 @@ export function clearToken(): void {
localStorage.removeItem('brainwave.auth.token') 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 { 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<T = unknown>( export async function apiFetch<T = unknown>(
+223 -3
View File
@@ -219,15 +219,216 @@ QUESTION TYPES
"answer": ["e1", "e2", "e3", "e4"] "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 LATEX SUPPORT
──────────────────────────────────── ────────────────────────────────────
Use LaTeX math in prompts, choices, and explanations: Use LaTeX math in prompts, choices, and explanations:
- Inline math: $x^2 + y^2 = z^2$ - 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: Remember to double-escape backslashes in JSON strings:
"prompt": "Evaluate $\\\\frac{d}{dx} x^2$" "prompt": "Evaluate $\\frac{d}{dx} x^2$"
──────────────────────────────────── ────────────────────────────────────
MEDIA ATTACHMENTS MEDIA ATTACHMENTS
@@ -304,10 +505,29 @@ When asking your LLM to create an exam, include:
4. Which question types to use 4. Which question types to use
5. Desired difficulty level 5. Desired difficulty level
6. Whether to include a time limit 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 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. 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' }) const blob = new Blob([content], { type: 'text/plain' })